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/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-148ef260d2db9d31004be8227fba5b18600ad700fd5741553a5db810d08ae382.json b/apps/labrinth/.sqlx/query-148ef260d2db9d31004be8227fba5b18600ad700fd5741553a5db810d08ae382.json
new file mode 100644
index 0000000000..0e5de23c71
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-148ef260d2db9d31004be8227fba5b18600ad700fd5741553a5db810d08ae382.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 coalesce(\n pag.attribution->'moderation_status'->>'kind',\n 'approved'\n ) != 'approved'\n )\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "version_id: DBVersionId",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "file_id: DBFileId",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 2,
+ "name": "flame_project",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true
+ ]
+ },
+ "hash": "148ef260d2db9d31004be8227fba5b18600ad700fd5741553a5db810d08ae382"
+}
diff --git a/apps/labrinth/.sqlx/query-623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3.json b/apps/labrinth/.sqlx/query-16e13baf35118cda943abf259a0e24cfe13436844b7909e6502ec15a249fc856.json
similarity index 56%
rename from apps/labrinth/.sqlx/query-623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3.json
rename to apps/labrinth/.sqlx/query-16e13baf35118cda943abf259a0e24cfe13436844b7909e6502ec15a249fc856.json
index 6ad1c4b9b5..d33825a8c3 100644
--- a/apps/labrinth/.sqlx/query-623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3.json
+++ b/apps/labrinth/.sqlx/query-16e13baf35118cda943abf259a0e24cfe13436844b7909e6502ec15a249fc856.json
@@ -1,30 +1,35 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT DISTINCT dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type\n FROM dependencies d\n WHERE dependent_id = ANY($1)\n ",
+ "query": "\n SELECT DISTINCT d.id as dependency_id, dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type\n FROM dependencies d\n WHERE dependent_id = ANY($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
+ "name": "dependency_id",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 1,
"name": "version_id",
"type_info": "Int8"
},
{
- "ordinal": 1,
+ "ordinal": 2,
"name": "dependency_project_id",
"type_info": "Int8"
},
{
- "ordinal": 2,
+ "ordinal": 3,
"name": "dependency_version_id",
"type_info": "Int8"
},
{
- "ordinal": 3,
+ "ordinal": 4,
"name": "file_name",
"type_info": "Varchar"
},
{
- "ordinal": 4,
+ "ordinal": 5,
"name": "dependency_type",
"type_info": "Varchar"
}
@@ -35,6 +40,7 @@
]
},
"nullable": [
+ false,
false,
true,
true,
@@ -42,5 +48,5 @@
false
]
},
- "hash": "623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3"
+ "hash": "16e13baf35118cda943abf259a0e24cfe13436844b7909e6502ec15a249fc856"
}
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-48a83bc9de9bea73eac5c43e71f43fb8a1ac7eb67079d16e3de92f5ff6bf72b8.json b/apps/labrinth/.sqlx/query-48a83bc9de9bea73eac5c43e71f43fb8a1ac7eb67079d16e3de92f5ff6bf72b8.json
new file mode 100644
index 0000000000..dac0ac1f3f
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-48a83bc9de9bea73eac5c43e71f43fb8a1ac7eb67079d16e3de92f5ff6bf72b8.json
@@ -0,0 +1,47 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\t\tselect\n\t\t\t\tpaf.group_id as \"group_id!\",\n\t\t\t\tpaf.name as \"name!\",\n\t\t\t\tconvert_from(paf.sha1, 'UTF8') as \"sha1!\",\n\t\t\t\tpaf.moderation_external_license_id,\n\t\t\t\tcoalesce(array_agg(distinct aev.id) filter (where aev.id is not null), '{}') as \"version_ids!: Vec\"\n\t\t\tfrom project_attribution_files paf\n\t\t\tleft join override_file_sources ofs on ofs.sha1 = paf.sha1\n\t\t\tleft join files f on f.id = ofs.file_id\n\t\t\tleft join versions v on v.id = f.version_id and v.mod_id = $2\n\t\t\tleft join attribution_enforced_versions aev on aev.id = v.id\n\t\t\twhere paf.group_id = ANY($1)\n\t\t\tgroup by paf.group_id, paf.name, paf.sha1, paf.moderation_external_license_id\n\t\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "group_id!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "name!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "sha1!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "moderation_external_license_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 4,
+ "name": "version_ids!: Vec",
+ "type_info": "Int8Array"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ null,
+ true,
+ null
+ ]
+ },
+ "hash": "48a83bc9de9bea73eac5c43e71f43fb8a1ac7eb67079d16e3de92f5ff6bf72b8"
+}
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-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-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-f453824f96385cccb703848189f25076fd9c1578e417d899d959a19dd9f940c3.json b/apps/labrinth/.sqlx/query-f453824f96385cccb703848189f25076fd9c1578e417d899d959a19dd9f940c3.json
new file mode 100644
index 0000000000..8618a4355c
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-f453824f96385cccb703848189f25076fd9c1578e417d899d959a19dd9f940c3.json
@@ -0,0 +1,40 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n select\n d.id as \"dependency_id!\",\n pag.attribution,\n pag.flame_project,\n pag.project_id as \"project_id: DBProjectId\"\n from dependencies d\n inner join files f on f.version_id = d.dependent_id\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join override_file_sources ofs on ofs.file_id = f.id\n inner join project_attribution_files paf on paf.sha1 = ofs.sha1\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where d.dependent_id = ANY($1)\n and d.dependency_file_name is not null\n and (\n pag.flame_project is not null\n or pag.attribution is not null\n )\n and split_part(paf.name, '/', -1) = d.dependency_file_name\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "dependency_id!",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 1,
+ "name": "attribution",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 2,
+ "name": "flame_project",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 3,
+ "name": "project_id: DBProjectId",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ true,
+ false
+ ]
+ },
+ "hash": "f453824f96385cccb703848189f25076fd9c1578e417d899d959a19dd9f940c3"
+}
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/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs
index 329e071042..970f9ab7b2 100644
--- a/apps/labrinth/src/auth/checks.rs
+++ b/apps/labrinth/src/auth/checks.rs
@@ -5,11 +5,39 @@ use crate::database::models::version_item::VersionQueryResult;
use crate::database::models::{DBCollection, DBOrganization, DBTeamMember};
use crate::database::redis::RedisPool;
use crate::database::{DBProject, DBVersion, models};
+use crate::models::ids::FileId;
+use crate::models::projects::{
+ MissingAttributionFile, OverrideSource, Version,
+};
use crate::models::users::User;
+use crate::queue::file_scan::{
+ get_dependency_attributions, get_files_missing_attribution,
+};
use crate::routes::ApiError;
use futures::TryStreamExt;
use itertools::Itertools;
+pub async fn enrich_dependency_attributions(
+ versions: &mut [VersionQueryResult],
+ pool: &PgPool,
+) {
+ let version_ids = versions.iter().map(|v| v.inner.id).collect::>();
+ let dep_attr = get_dependency_attributions(pool, &version_ids)
+ .await
+ .unwrap_or_default();
+
+ for version in versions {
+ for dep in &mut version.dependencies {
+ if let Some(attr) = dep_attr.get(&dep.id)
+ && (attr.attribution.flame_project.is_some()
+ || attr.attribution.resolution.is_some())
+ {
+ dep.attribution = Some(attr.attribution.clone());
+ }
+ }
+ }
+}
+
pub trait ValidateAuthorized {
fn validate_authorized(
&self,
@@ -204,7 +232,42 @@ pub async fn filter_visible_versions(
)
.await?;
versions.retain(|x| filtered_version_ids.contains(&x.inner.id));
- Ok(versions.into_iter().map(|x| x.into()).collect())
+
+ let version_ids: Vec<_> = versions.iter().map(|v| v.inner.id).collect();
+ let missing = get_files_missing_attribution(pool, &version_ids)
+ .await
+ .unwrap_or_default();
+
+ enrich_dependency_attributions(&mut versions, pool).await;
+
+ Ok(versions
+ .into_iter()
+ .map(|v| {
+ let files_missing = missing
+ .get(&v.inner.id)
+ .map(|entries| {
+ entries
+ .iter()
+ .map(|(id, fp)| MissingAttributionFile {
+ id: FileId(id.0 as u64),
+ override_source: fp
+ .as_ref()
+ .map(|p| OverrideSource::Flame {
+ id: p.id,
+ title: p.title.clone(),
+ url: p.url.clone(),
+ icon_url: p.icon_url.clone(),
+ })
+ .or(Some(OverrideSource::Unknown)),
+ })
+ .collect::>()
+ })
+ .unwrap_or_default();
+ let mut version = Version::from(v);
+ version.files_missing_attribution = files_missing;
+ version
+ })
+ .collect())
}
impl ValidateAuthorized for models::DBOAuthClient {
@@ -258,13 +321,20 @@ pub async fn filter_visible_version_ids(
filter_enlisted_version_ids(versions.clone(), user_option, pool, redis)
.await?;
+ let version_ids: Vec<_> = versions.iter().map(|v| v.id).collect();
+ let withheld_versions = get_files_missing_attribution(pool, &version_ids)
+ .await
+ .unwrap_or_default();
+
// Return versions that are not hidden, we are a mod of, or we are enlisted on the team of
for version in versions {
+ let is_withheld = withheld_versions.contains_key(&version.id);
// We can see the version if:
- // - it's not hidden and we can see the project
+ // - it's not hidden and we can see the project and it's not withheld for attribution
// - we are a mod
// - we are enlisted on the team of the mod
if (!version.status.is_hidden()
+ && !is_withheld
&& visible_project_ids.contains(&version.project_id))
|| user_option.as_ref().is_some_and(|x| x.role.is_mod())
|| enlisted_version_ids.contains(&version.id)
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..5e585362a1 100644
--- a/apps/labrinth/src/database/models/ids.rs
+++ b/apps/labrinth/src/database/models/ids.rs
@@ -1,12 +1,13 @@
use super::DatabaseError;
use crate::database::PgTransaction;
use crate::models::ids::{
- AffiliateCodeId, AnalyticsEventId, CampaignDonationId, ChargeId,
- CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId,
- OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId,
- OrganizationId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId,
- ReportId, SessionId, SharedInstanceId, SharedInstanceVersionId, TeamId,
- TeamMemberId, ThreadId, ThreadMessageId, UserSubscriptionId, VersionId,
+ AffiliateCodeId, AnalyticsEventId, AttributionGroupId, CampaignDonationId,
+ ChargeId, CollectionId, FileId, ImageId, NotificationId,
+ OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId,
+ OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId,
+ ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId,
+ SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId, ThreadMessageId,
+ UserSubscriptionId, VersionId,
};
use ariadne::ids::base62_impl::to_base62;
use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range};
@@ -172,6 +173,10 @@ db_id_interface!(
CollectionId,
generator: generate_collection_id @ "collections",
);
+db_id_interface!(
+ AttributionGroupId,
+ generator: generate_attribution_group_id @ "project_attribution_groups",
+);
db_id_interface!(
FileId,
generator: generate_file_id @ "files",
diff --git a/apps/labrinth/src/database/models/notification_item.rs b/apps/labrinth/src/database/models/notification_item.rs
index 14fdb7198d..dda5b230f7 100644
--- a/apps/labrinth/src/database/models/notification_item.rs
+++ b/apps/labrinth/src/database/models/notification_item.rs
@@ -204,10 +204,11 @@ impl NotificationBuilder {
users: Vec,
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
- ) -> Result<(), DatabaseError> {
- self.insert_many_records(&users, transaction).await?;
+ ) -> Result, DatabaseError> {
+ let notification_ids =
+ self.insert_many_records(&users, transaction).await?;
DBNotification::clear_user_notifications_cache(&users, redis).await?;
- Ok(())
+ Ok(notification_ids)
}
pub async fn insert_many_deliveries(
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..f8cec5a651 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!(
@@ -716,7 +778,7 @@ impl DBVersion {
let dependencies : DashMap> = sqlx::query!(
"
- SELECT DISTINCT dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type
+ SELECT DISTINCT d.id as dependency_id, dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type
FROM dependencies d
WHERE dependent_id = ANY($1)
",
@@ -724,10 +786,12 @@ impl DBVersion {
).fetch(&mut exec)
.try_fold(DashMap::new(), |acc : DashMap<_,Vec>, m| {
let dependency = DependencyQueryResult {
+ id: m.dependency_id,
project_id: m.dependency_project_id.map(DBProjectId),
version_id: m.dependency_version_id.map(DBVersionId),
file_name: m.file_name,
dependency_type: m.dependency_type,
+ attribution: None,
};
acc.entry(DBVersionId(m.version_id))
@@ -862,14 +926,14 @@ impl DBVersion {
})
}
- pub async fn get_files_from_hash<'a, 'b, E>(
+ pub async fn get_files_from_hash<'a, E>(
algorithm: String,
hashes: &[String],
executor: E,
redis: &RedisPool,
) -> Result, DatabaseError>
where
- E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy,
+ E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
let val = redis.get_cached_keys(
VERSION_FILES_NAMESPACE,
@@ -977,10 +1041,12 @@ pub struct VersionQueryResult {
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct DependencyQueryResult {
+ pub id: i32,
pub project_id: Option,
pub version_id: Option,
pub file_name: Option,
pub dependency_type: String,
+ pub attribution: Option,
}
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
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/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/projects.rs b/apps/labrinth/src/models/v3/projects.rs
index 92abe3fddb..99436576f5 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,98 @@ 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,
+)]
+pub struct FlameProject {
+ pub id: u32,
+ pub title: String,
+ pub url: String,
+ pub icon_url: String,
+}
+
+#[derive(
+ Debug, Serialize, Deserialize, Clone, PartialEq, Eq, utoipa::ToSchema,
+)]
+#[serde(untagged)]
+pub enum AttributionLicense {
+ Spdx(String),
+ Custom { name: String },
+}
+
+#[derive(
+ Debug, Serialize, Deserialize, Clone, PartialEq, Eq, utoipa::ToSchema,
+)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum AttributionResolutionKind {
+ License {
+ license: AttributionLicense,
+ link_to_work: Url,
+ },
+ GloballyAllowed {
+ link_to_work: Url,
+ },
+ MyProject {
+ license: AttributionLicense,
+ },
+ SpecialPermissions {
+ link_to_work: Url,
+ },
+ NoPermission {
+ link_to_work: Option,
+ },
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum AttributionModerationStatusKind {
+ NotAllowed,
+ Approved,
+ BadProof,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
+pub struct AttributionModerationStatus {
+ #[serde(flatten)]
+ pub kind: AttributionModerationStatusKind,
+ #[serde(default)]
+ pub reason: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub moderated_at: Option>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub moderated_by: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
+pub struct AttributionResolution {
+ #[serde(flatten)]
+ pub kind: AttributionResolutionKind,
+ #[serde(default)]
+ pub moderation_status: Option,
+ #[serde(default)]
+ pub updated_by_moderator: bool,
+ 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 +774,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 +853,7 @@ impl From for Version {
dependency_type: DependencyType::from_string(
d.dependency_type.as_str(),
),
+ attribution: d.attribution,
})
.collect(),
loaders: data.loaders.into_iter().map(Loader).collect(),
@@ -768,6 +865,7 @@ impl From for Version {
.map(|vf| (vf.field_name, vf.value.serialize_internal()))
.collect(),
components: data.components,
+ files_missing_attribution: Vec::new(),
}
}
}
@@ -899,6 +997,18 @@ pub struct Dependency {
pub file_name: Option,
/// 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 flame_project: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub resolution: Option,
}
#[derive(
diff --git a/apps/labrinth/src/queue/file_scan.rs b/apps/labrinth/src/queue/file_scan.rs
new file mode 100644
index 0000000000..dc490735e3
--- /dev/null
+++ b/apps/labrinth/src/queue/file_scan.rs
@@ -0,0 +1,1000 @@
+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, DependencyAttribution,
+ FlameProject,
+};
+use crate::queue::moderation::{
+ ApprovalType, FingerprintResponse, FlameResponse,
+};
+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(FlameProject),
+ 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,
+ updated_by_moderator: false,
+ 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,
+ updated_by_moderator: false,
+ 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::<
+ FlameResponse<
+ Vec,
+ >,
+ >(&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 = FlameProject {
+ 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 coalesce(
+ pag.attribution->'moderation_status'->>'kind',
+ 'approved'
+ ) != '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 attribution: DependencyAttribution,
+}
+
+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.id as "dependency_id!",
+ 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.flame_project is not null
+ or pag.attribution is not null
+ )
+ 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 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 resolution = attribution.map(|a| a.kind);
+
+ result.insert(
+ row.dependency_id,
+ DependencyAttributionData {
+ attribution: DependencyAttribution {
+ flame_project,
+ resolution,
+ },
+ },
+ );
+ }
+
+ 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..48f55a6c06 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::>()
}))
@@ -579,7 +579,7 @@ impl AutomatedModerationQueue {
.text()
.await?;
- serde_json::from_str::>>(&res)?.data
+ serde_json::from_str::>>(&res)?.data
};
let mut missing_metadata = MissingMetadata {
@@ -823,7 +823,7 @@ pub enum ApprovalType {
}
impl ApprovalType {
- fn approved(&self) -> bool {
+ pub fn approved(&self) -> bool {
match self {
ApprovalType::Yes => true,
ApprovalType::WithAttributionAndSource => true,
@@ -896,11 +896,18 @@ pub struct FlameFileHash {
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
-pub struct FlameProject {
+pub struct FlameProjectResponse {
pub id: u32,
pub name: String,
pub slug: String,
pub links: FlameLinks,
+ pub logo: FlameLogo,
+}
+
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FlameLogo {
+ pub thumbnail_url: String,
}
#[derive(Deserialize, Serialize)]
diff --git a/apps/labrinth/src/routes/internal/attribution.rs b/apps/labrinth/src/routes/internal/attribution.rs
new file mode 100644
index 0000000000..aef91575eb
--- /dev/null
+++ b/apps/labrinth/src/routes/internal/attribution.rs
@@ -0,0 +1,642 @@
+use actix_web::{HttpRequest, get, patch, post, web};
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+
+use crate::auth::get_user_from_headers;
+use crate::database::PgPool;
+use crate::database::models::ids::{
+ DBAttributionGroupId, DBProjectId, generate_attribution_group_id,
+};
+use crate::database::redis::RedisPool;
+use crate::models::ids::{ProjectId, VersionId};
+use crate::models::pats::Scopes;
+use crate::models::projects::{
+ AttributionModerationStatusKind, AttributionResolution,
+ AttributionResolutionKind, FlameProject,
+};
+use crate::models::users::User;
+use crate::queue::moderation::ApprovalType;
+use crate::queue::session::AuthQueue;
+use crate::routes::ApiError;
+
+pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
+ cfg.service(list)
+ .service(update_group)
+ .service(assign)
+ .service(split);
+}
+
+#[derive(Serialize)]
+struct AttributionGroupResponse {
+ id: crate::models::ids::AttributionGroupId,
+ flame_project: Option,
+ 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 requester_is_mod = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_READ,
+ )
+ .await
+ .ok()
+ .is_some_and(|(_, user)| user.role.is_mod());
+
+ let groups = sqlx::query!(
+ r#"
+ select
+ g.id as "id: DBAttributionGroupId",
+ g.flame_project,
+ g.attribution,
+ g.attributed_at,
+ g.attributed_by as "attributed_by: i64"
+ from project_attribution_groups g
+ where g.project_id = $1
+ "#,
+ project_id as DBProjectId,
+ )
+ .fetch_all(pool.as_ref())
+ .await?;
+
+ let group_ids: Vec = groups.iter().map(|g| g.id.0).collect();
+
+ let files = if group_ids.is_empty() {
+ Vec::new()
+ } else {
+ sqlx::query!(
+ r#"
+ select
+ paf.group_id as "group_id!",
+ paf.name as "name!",
+ convert_from(paf.sha1, 'UTF8') as "sha1!",
+ paf.moderation_external_license_id,
+ coalesce(array_agg(distinct aev.id) filter (where aev.id is not null), '{}') as "version_ids!: Vec"
+ from project_attribution_files paf
+ left join override_file_sources ofs on ofs.sha1 = paf.sha1
+ left join files f on f.id = ofs.file_id
+ left join versions v on v.id = f.version_id and v.mod_id = $2
+ left join attribution_enforced_versions aev on aev.id = v.id
+ where paf.group_id = ANY($1)
+ group by paf.group_id, paf.name, paf.sha1, paf.moderation_external_license_id
+ "#,
+ &group_ids,
+ project_id as DBProjectId,
+ )
+ .fetch_all(pool.as_ref())
+ .await?
+ };
+
+ let moderation_external_licenses = if requester_is_mod {
+ let mut ids: Vec = files
+ .iter()
+ .filter_map(|f| f.moderation_external_license_id)
+ .collect();
+ ids.sort_unstable();
+ ids.dedup();
+
+ if ids.is_empty() {
+ std::collections::HashMap::new()
+ } else {
+ sqlx::query!(
+ r#"
+ select
+ id,
+ title,
+ status,
+ link,
+ exceptions,
+ proof,
+ flame_project_id,
+ inserted_at,
+ inserted_by,
+ updated_at,
+ updated_by
+ from moderation_external_licenses
+ where id = ANY($1)
+ "#,
+ &ids,
+ )
+ .fetch_all(pool.as_ref())
+ .await?
+ .into_iter()
+ .map(|row| {
+ (
+ row.id,
+ ModerationExternalLicenseResponse {
+ id: row.id,
+ title: row.title,
+ status: ApprovalType::from_string(&row.status)
+ .unwrap_or(ApprovalType::Unidentified),
+ link: row.link,
+ exceptions: row.exceptions,
+ proof: row.proof,
+ flame_project_id: row.flame_project_id,
+ inserted_at: row.inserted_at,
+ inserted_by: row.inserted_by,
+ updated_at: row.updated_at,
+ updated_by: row.updated_by,
+ },
+ )
+ })
+ .collect()
+ }
+ } else {
+ std::collections::HashMap::new()
+ };
+
+ let mut all_version_ids: Vec = files
+ .iter()
+ .flat_map(|f| f.version_ids.iter().copied())
+ .collect();
+ all_version_ids.sort_unstable();
+ all_version_ids.dedup();
+
+ let version_infos = if all_version_ids.is_empty() {
+ Vec::new()
+ } else {
+ let rows = sqlx::query!(
+ "
+ select id, name, version_number, date_published
+ from versions
+ where id = ANY($1)
+ order by date_published desc
+ ",
+ &all_version_ids,
+ )
+ .fetch_all(pool.as_ref())
+ .await?;
+ rows.into_iter()
+ .map(|v| VersionInfo {
+ id: VersionId(v.id as u64),
+ name: v.name,
+ version_number: v.version_number,
+ date_created: v.date_published,
+ })
+ .collect()
+ };
+ let version_order = version_infos
+ .iter()
+ .enumerate()
+ .map(|(index, version)| (version.id, index))
+ .collect::>();
+
+ let mut result = Vec::new();
+ for group in groups {
+ let group_files: Vec = files
+ .iter()
+ .filter(|f| f.group_id == group.id.0)
+ .map(|f| AttributionFileResponse {
+ name: f.name.clone(),
+ sha1: f.sha1.clone(),
+ moderation_external_license_id: if requester_is_mod {
+ f.moderation_external_license_id
+ } else {
+ None
+ },
+ moderation_external_license: if requester_is_mod {
+ f.moderation_external_license_id.and_then(|id| {
+ moderation_external_licenses.get(&id).cloned()
+ })
+ } else {
+ None
+ },
+ versions: {
+ let mut versions: Vec<_> = f
+ .version_ids
+ .iter()
+ .copied()
+ .map(|id| VersionId(id as u64))
+ .collect();
+ versions.sort_by_key(|id| {
+ version_order.get(id).copied().unwrap_or(usize::MAX)
+ });
+ versions
+ },
+ })
+ .collect();
+ let group_version_ids = group_files
+ .iter()
+ .flat_map(|file| file.versions.iter().copied())
+ .collect::>();
+ let group_versions = version_infos
+ .iter()
+ .filter(|version| group_version_ids.contains(&version.id))
+ .cloned()
+ .collect();
+
+ let mut attribution = group.attribution.and_then(|v| {
+ serde_json::from_value::(v).ok()
+ });
+ if let Some(moderation_status) = attribution
+ .as_mut()
+ .and_then(|a| a.moderation_status.as_mut())
+ && !requester_is_mod
+ {
+ moderation_status.moderated_at = None;
+ moderation_status.moderated_by = None;
+ }
+ let attributed_by = if attribution
+ .as_ref()
+ .is_some_and(|attribution| attribution.updated_by_moderator)
+ && !requester_is_mod
+ {
+ None
+ } else {
+ group
+ .attributed_by
+ .map(|id| ariadne::ids::UserId(id as u64))
+ };
+
+ result.push(AttributionGroupResponse {
+ id: group.id.into(),
+ flame_project: group
+ .flame_project
+ .and_then(|v| serde_json::from_value(v).ok()),
+ attribution,
+ attributed_at: group.attributed_at,
+ attributed_by,
+ files: group_files,
+ versions: group_versions,
+ });
+ }
+
+ Ok(web::Json(result))
+}
+
+#[derive(Deserialize, utoipa::ToSchema)]
+struct UpdateGroupBody {
+ attribution: AttributionResolution,
+}
+
+#[utoipa::path]
+#[patch("/group/{group_id}")]
+async fn update_group(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ path: web::Path,
+ web::Json(body): web::Json,
+) -> Result<(), ApiError> {
+ let group_id = path.into_inner();
+ let user = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::VERSION_WRITE,
+ )
+ .await?
+ .1;
+
+ if !can_edit_attribution_group(pool.as_ref(), group_id, &user).await? {
+ return Err(ApiError::CustomAuthentication(
+ "This attribution group cannot be edited".to_string(),
+ ));
+ }
+
+ if matches!(
+ body.attribution.kind,
+ AttributionResolutionKind::GloballyAllowed { .. }
+ ) && !user.role.is_mod()
+ {
+ return Err(ApiError::CustomAuthentication(
+ "Only moderators can set globally allowed attributions".to_string(),
+ ));
+ }
+
+ if body.attribution.moderation_status.is_some() && !user.role.is_mod() {
+ return Err(ApiError::CustomAuthentication(
+ "Only moderators can set attribution moderation status".to_string(),
+ ));
+ }
+
+ let mut attribution = body.attribution;
+ attribution.updated_by_moderator = user.role.is_mod();
+ if let Some(moderation_status) = &mut attribution.moderation_status {
+ moderation_status.moderated_at = Some(Utc::now());
+ moderation_status.moderated_by = Some(user.id);
+ }
+
+ let result = sqlx::query!(
+ "
+ update project_attribution_groups
+ set attribution = $1, attributed_at = now(), attributed_by = $3
+ where id = $2
+ ",
+ &serde_json::to_value(&attribution).unwrap_or_default(),
+ group_id,
+ user.id.0 as i64,
+ )
+ .execute(pool.as_ref())
+ .await?;
+
+ if result.rows_affected() == 0 {
+ return Err(ApiError::NotFound);
+ }
+
+ Ok(())
+}
+
+#[derive(Deserialize, utoipa::ToSchema)]
+struct AssignBody {
+ sha1: String,
+ target_group_id: i64,
+ project_id: ProjectId,
+}
+
+#[utoipa::path]
+#[post("/assign")]
+async fn assign(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ web::Json(body): web::Json,
+) -> Result<(), ApiError> {
+ let user = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::VERSION_WRITE,
+ )
+ .await?
+ .1;
+
+ let sha1 = body.sha1.trim().to_lowercase();
+ if hex_to_bytes(&sha1).is_none() {
+ return Err(ApiError::InvalidInput(
+ "invalid sha1 hex string".to_string(),
+ ));
+ }
+ let sha1_bytes = sha1.as_bytes().to_vec();
+ let project_id: DBProjectId = body.project_id.into();
+
+ let source_group_id = sqlx::query_scalar!(
+ "
+ select paf.group_id
+ from project_attribution_files paf
+ inner join project_attribution_groups pag on pag.id = paf.group_id
+ where paf.sha1 = $1 and pag.project_id = $2
+ ",
+ &sha1_bytes,
+ project_id as DBProjectId,
+ )
+ .fetch_optional(pool.as_ref())
+ .await?
+ .ok_or(ApiError::NotFound)?;
+
+ let target_group_exists = sqlx::query_scalar!(
+ "
+ select exists(
+ select 1 from project_attribution_groups where id = $1 and project_id = $2
+ ) as \"exists!\"
+ ",
+ body.target_group_id,
+ project_id as DBProjectId,
+ )
+ .fetch_one(pool.as_ref())
+ .await?;
+
+ if !target_group_exists {
+ return Err(ApiError::NotFound);
+ }
+
+ if !can_edit_attribution_group(pool.as_ref(), source_group_id, &user)
+ .await?
+ || !can_edit_attribution_group(
+ pool.as_ref(),
+ body.target_group_id,
+ &user,
+ )
+ .await?
+ {
+ return Err(ApiError::CustomAuthentication(
+ "This attribution group cannot be edited".to_string(),
+ ));
+ }
+
+ let result = sqlx::query!(
+ "
+ update project_attribution_files
+ set group_id = $1
+ where sha1 = $2
+ and group_id in (
+ select id from project_attribution_groups where project_id = $3
+ )
+ ",
+ body.target_group_id,
+ &sha1_bytes,
+ project_id as DBProjectId,
+ )
+ .execute(pool.as_ref())
+ .await?;
+
+ if result.rows_affected() == 0 {
+ return Err(ApiError::NotFound);
+ }
+
+ cleanup_empty_groups(pool.as_ref()).await?;
+
+ Ok(())
+}
+
+#[derive(Deserialize, utoipa::ToSchema)]
+struct SplitBody {
+ sha1: String,
+ project_id: ProjectId,
+}
+
+#[utoipa::path]
+#[post("/split")]
+async fn split(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ web::Json(body): web::Json,
+) -> Result<(), ApiError> {
+ let user = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::VERSION_WRITE,
+ )
+ .await?
+ .1;
+
+ let sha1 = body.sha1.trim().to_lowercase();
+ if hex_to_bytes(&sha1).is_none() {
+ return Err(ApiError::InvalidInput(
+ "invalid sha1 hex string".to_string(),
+ ));
+ }
+ let sha1_bytes = sha1.as_bytes().to_vec();
+ let project_id: DBProjectId = body.project_id.into();
+
+ let existing = sqlx::query!(
+ "
+ select paf.group_id, paf.name from project_attribution_files paf
+ inner join project_attribution_groups pag on pag.id = paf.group_id
+ where paf.sha1 = $1 and pag.project_id = $2
+ ",
+ &sha1_bytes,
+ project_id as DBProjectId,
+ )
+ .fetch_optional(pool.as_ref())
+ .await?;
+
+ let Some(existing) = existing else {
+ return Err(ApiError::NotFound);
+ };
+
+ if !can_edit_attribution_group(pool.as_ref(), existing.group_id, &user)
+ .await?
+ {
+ return Err(ApiError::CustomAuthentication(
+ "This attribution group cannot be edited".to_string(),
+ ));
+ }
+
+ let mut txn = pool.begin().await?;
+
+ let new_group_id = generate_attribution_group_id(&mut txn).await?;
+
+ sqlx::query!(
+ "
+ insert into project_attribution_groups (id, project_id)
+ values ($1, $2)
+ ",
+ new_group_id as DBAttributionGroupId,
+ project_id as DBProjectId,
+ )
+ .execute(&mut txn)
+ .await?;
+
+ sqlx::query!(
+ "
+ update project_attribution_files
+ set group_id = $1
+ where sha1 = $2 and group_id = $3
+ ",
+ new_group_id as DBAttributionGroupId,
+ &sha1_bytes,
+ existing.group_id,
+ )
+ .execute(&mut txn)
+ .await?;
+
+ txn.commit().await?;
+
+ cleanup_empty_groups(pool.as_ref()).await?;
+
+ Ok(())
+}
+
+async fn can_edit_attribution_group(
+ pool: &PgPool,
+ group_id: i64,
+ user: &User,
+) -> Result {
+ if user.role.is_mod() {
+ return Ok(true);
+ }
+
+ let attribution = sqlx::query_scalar!(
+ "
+ select attribution
+ from project_attribution_groups
+ where id = $1
+ ",
+ group_id,
+ )
+ .fetch_optional(pool)
+ .await?
+ .ok_or(ApiError::NotFound)?;
+
+ let attribution: Option =
+ attribution.and_then(|value| serde_json::from_value(value).ok());
+
+ Ok(!matches!(
+ attribution
+ .and_then(|attribution| attribution.moderation_status)
+ .map(|status| status.kind),
+ Some(AttributionModerationStatusKind::NotAllowed)
+ ))
+}
+
+async fn cleanup_empty_groups(pool: &PgPool) -> Result<(), ApiError> {
+ sqlx::query!(
+ "
+ delete from project_attribution_groups g
+ where not exists (
+ select 1 from project_attribution_files f where f.group_id = g.id
+ )
+ ",
+ )
+ .execute(pool)
+ .await?;
+ Ok(())
+}
+
+fn hex_to_bytes(hex: &str) -> Option> {
+ if !hex.len().is_multiple_of(2) {
+ return None;
+ }
+ (0..hex.len())
+ .step_by(2)
+ .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
+ .collect()
+}
diff --git a/apps/labrinth/src/routes/internal/external_notifications.rs b/apps/labrinth/src/routes/internal/external_notifications.rs
index 3381af196d..b276bd3944 100644
--- a/apps/labrinth/src/routes/internal/external_notifications.rs
+++ b/apps/labrinth/src/routes/internal/external_notifications.rs
@@ -242,6 +242,152 @@ pub async fn remove(
Ok(HttpResponse::NoContent().finish())
}
+/// Inserts notifications for all users and tries to send emails immediately.
+///
+/// Responds with the user IDs that could not be emailed:
+/// - `200` if every recipient was emailed (empty list)
+/// - `207` if some recipients could not be emailed (list of failed IDs)
+#[post(
+ "external_notifications/email-sync",
+ guard = "external_notification_key_guard"
+)]
+pub async fn create_email_sync(
+ pool: web::Data,
+ redis: web::Data,
+ email_queue: web::Data,
+ create_notification: web::Json,
+) -> Result>>, ApiError> {
+ let CreateNotification { body, user_ids } =
+ create_notification.into_inner();
+ let raw_user_ids = user_ids.iter().map(|x| x.0 as i64).collect::>();
+
+ let user_ids = raw_user_ids
+ .iter()
+ .map(|x| DBUserId(*x))
+ .collect::>();
+
+ let mut txn = pool.begin().await?;
+
+ if !DBUser::exists_many(&user_ids, &mut txn).await? {
+ return Err(ApiError::InvalidInput(
+ "One of the specified users do not exist.".to_owned(),
+ ));
+ }
+
+ // Skip users who already have an identical notification
+ let body_value = serde_json::value::to_value(&body)?;
+ let already_notified = sqlx::query!(
+ "
+ SELECT DISTINCT user_id
+ FROM notifications
+ WHERE user_id = ANY($1::bigint[]) AND body = $2::jsonb
+ ",
+ &raw_user_ids[..],
+ body_value,
+ )
+ .fetch_all(&mut txn)
+ .await?
+ .into_iter()
+ .map(|row| DBUserId(row.user_id))
+ .collect::>();
+
+ let notification_user_ids = user_ids
+ .clone()
+ .into_iter()
+ .filter(|id| !already_notified.contains(id))
+ .collect::>();
+
+ NotificationBuilder { body: body.clone() }
+ .insert_many_without_delivery(notification_user_ids, &mut txn, &redis)
+ .await?;
+
+ txn.commit().await?;
+
+ let mut email_txn = pool.begin().await?;
+
+ let mut failed = Vec::new();
+ for user_id in &user_ids {
+ let Some(user) =
+ DBUser::get_id(*user_id, &mut email_txn, &redis).await?
+ else {
+ failed.push(UserId(user_id.0 as u64));
+ continue;
+ };
+
+ let delivered = match user
+ .email
+ .and_then(|email| email.parse::().ok())
+ {
+ Some(mailbox) => {
+ email_queue
+ .send_one(&mut email_txn, body.clone(), *user_id, mailbox)
+ .await?
+ == NotificationDeliveryStatus::Delivered
+ }
+ None => false,
+ };
+
+ if !delivered {
+ failed.push(UserId(user_id.0 as u64));
+ }
+ }
+
+ let status = if failed.is_empty() {
+ StatusCode::OK
+ } else {
+ StatusCode::MULTI_STATUS
+ };
+
+ Ok(web::Json(failed).customize().with_status(status))
+}
+
+#[derive(Deserialize)]
+struct NotificationFilter {
+ pub user_ids: Vec,
+ #[serde(flatten)]
+ pub body: serde_json::Map,
+}
+
+#[delete("external_notifications", guard = "external_notification_key_guard")]
+pub async fn remove(
+ pool: web::Data,
+ redis: web::Data,
+ notification_filter: web::Json,
+) -> Result {
+ let NotificationFilter { user_ids, body } =
+ notification_filter.into_inner();
+
+ if user_ids.is_empty() {
+ return Err(ApiError::Request(eyre!(
+ "at least one user must be provided to remove notifications from"
+ )));
+ }
+
+ if body.is_empty() {
+ return Err(ApiError::Request(eyre!(
+ "at least one `body` field must be provided to match notifications"
+ )));
+ }
+
+ let filters = serde_json::Value::Object(body);
+
+ let user_ids = user_ids
+ .into_iter()
+ .map(|x| DBUserId(x.0 as i64))
+ .collect::>();
+
+ let mut txn = pool.begin().await?;
+
+ DBNotification::remove_many_matching_body(
+ &filters, &user_ids, &mut txn, &redis,
+ )
+ .await?;
+
+ txn.commit().await?;
+
+ Ok(HttpResponse::NoContent().finish())
+}
+
#[derive(Deserialize)]
struct SendEmail {
pub users: Vec,
diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs
index 871c6a5fd0..16d9329196 100644
--- a/apps/labrinth/src/routes/internal/flows.rs
+++ b/apps/labrinth/src/routes/internal/flows.rs
@@ -38,7 +38,6 @@ use reqwest::header::AUTHORIZATION;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
-use std::sync::Arc;
use tracing::info;
use validator::Validate;
use zxcvbn::Score;
@@ -83,7 +82,7 @@ impl TempUser {
provider: AuthProvider,
transaction: &mut PgTransaction<'_>,
client: &PgPool,
- file_host: &Arc,
+ file_host: &dyn FileHost,
redis: &RedisPool,
) -> Result {
if let Some(email) = &self.email
@@ -150,7 +149,7 @@ impl TempUser {
ext,
Some(96),
Some(1.0),
- &**file_host,
+ file_host,
)
.await;
@@ -1173,7 +1172,7 @@ pub async fn auth_callback(
req: HttpRequest,
Query(query): Query>,
client: Data,
- file_host: Data>,
+ file_host: Data,
redis: Data,
) -> Result {
let state_string = query
@@ -1331,7 +1330,7 @@ pub async fn auth_callback(
provider,
&mut transaction,
&client,
- &file_host,
+ &**file_host,
&redis,
)
.await?
diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs
index 2af8ae81f3..94a4b4343d 100644
--- a/apps/labrinth/src/routes/internal/mod.rs
+++ b/apps/labrinth/src/routes/internal/mod.rs
@@ -1,5 +1,6 @@
pub mod admin;
pub mod affiliate;
+pub mod attribution;
pub mod billing;
pub mod campaign;
pub mod delphi;
@@ -105,5 +106,10 @@ pub fn utoipa_config(
utoipa_actix_web::scope("/_internal/server-ping")
.wrap(default_cors())
.configure(server_ping::config),
+ )
+ .service(
+ utoipa_actix_web::scope("/_internal/attribution")
+ .wrap(default_cors())
+ .configure(attribution::config),
);
}
diff --git a/apps/labrinth/src/routes/internal/moderation/external_license.rs b/apps/labrinth/src/routes/internal/moderation/external_license.rs
index cbcf03b245..6ef567ee34 100644
--- a/apps/labrinth/src/routes/internal/moderation/external_license.rs
+++ b/apps/labrinth/src/routes/internal/moderation/external_license.rs
@@ -5,6 +5,8 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::database::PgPool;
+use crate::database::models::ids::DBUserId;
+use crate::database::models::moderation_external_item::ExternalLicense;
use crate::database::redis::RedisPool;
use crate::models::pats::Scopes;
use crate::queue::moderation::ApprovalType;
@@ -14,7 +16,11 @@ use crate::{auth::check_is_moderator_from_headers, queue::session::AuthQueue};
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(search)
.service(get_by_sha1)
- .service(update_license);
+ .service(get_by_sha1_bulk)
+ .service(lookup)
+ .service(update_license)
+ .service(add_file)
+ .service(reassign_file);
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
@@ -43,6 +49,26 @@ pub struct LinkedFile {
pub struct SearchRequest {
pub title: Option,
pub flame_id: Option,
+ pub flame_ids: Option>,
+}
+
+#[derive(Deserialize, utoipa::ToSchema)]
+pub struct HashLookupRequest {
+ pub hashes: Vec,
+}
+
+#[derive(Deserialize, utoipa::ToSchema)]
+pub struct ExternalLicenseLookupRequest {
+ #[serde(default)]
+ pub flame_ids: Vec,
+ #[serde(default)]
+ pub hashes: Vec,
+}
+
+#[derive(Serialize, utoipa::ToSchema)]
+pub struct ExternalLicenseLookupResponse {
+ pub flame_ids: HashMap>,
+ pub hashes: HashMap,
}
#[derive(Deserialize, utoipa::ToSchema)]
@@ -55,6 +81,32 @@ pub struct UpdateLicenseRequest {
pub flame_project_id: Option,
}
+#[derive(Deserialize, utoipa::ToSchema)]
+pub struct FileLicenseRequest {
+ pub hashes: Vec,
+ pub license_id: LicenseId,
+}
+
+#[derive(Deserialize, utoipa::ToSchema)]
+#[serde(untagged)]
+pub enum LicenseId {
+ Number(i64),
+ String(String),
+}
+
+impl LicenseId {
+ fn parse(self) -> Result {
+ match self {
+ LicenseId::Number(id) => Ok(id),
+ LicenseId::String(id) => id.parse().map_err(|_| {
+ ApiError::InvalidInput(
+ "license_id must be a valid integer".to_string(),
+ )
+ }),
+ }
+ }
+}
+
struct LicenseRow {
id: i64,
title: Option,
@@ -69,6 +121,38 @@ struct LicenseRow {
updated_by: Option,
}
+struct LicenseHashRow {
+ hash: Vec,
+ id: i64,
+ title: Option,
+ status: String,
+ link: Option,
+ exceptions: Option,
+ proof: Option,
+ flame_project_id: Option,
+ inserted_at: Option>,
+ inserted_by: Option,
+ updated_at: Option>,
+ updated_by: Option,
+}
+
+fn normalize_sha1_hashes(hashes: &[String]) -> Result, ApiError> {
+ hashes
+ .iter()
+ .map(|hash| {
+ let hash = hash.trim().to_lowercase();
+ if hash.len() != 40 || !hash.chars().all(|c| c.is_ascii_hexdigit())
+ {
+ return Err(ApiError::InvalidInput(
+ "hash must be a valid SHA1 hex string".to_string(),
+ ));
+ }
+
+ Ok(hash)
+ })
+ .collect()
+}
+
impl LicenseRow {
fn into_external_project(
self,
@@ -120,12 +204,131 @@ async fn fetch_linked_files(
.or_default()
.push(LinkedFile {
name: row.filename,
- sha1: hex::encode(&row.sha1),
+ sha1: String::from_utf8(row.sha1)
+ .unwrap_or_else(|err| hex::encode(err.into_bytes())),
});
}
Ok(map)
}
+async fn fetch_by_hashes(
+ pool: &PgPool,
+ hashes: &[String],
+) -> Result, ApiError> {
+ if hashes.is_empty() {
+ return Ok(HashMap::new());
+ }
+
+ let hash_bytes = hashes
+ .iter()
+ .map(|hash| hash.as_bytes().to_vec())
+ .collect::>();
+
+ let rows = sqlx::query_as!(
+ LicenseHashRow,
+ r#"
+ SELECT
+ mef.sha1 hash,
+ mel.id,
+ mel.title,
+ mel.status,
+ mel.link,
+ mel.exceptions,
+ mel.proof,
+ mel.flame_project_id,
+ mel.inserted_at,
+ mel.inserted_by,
+ mel.updated_at,
+ mel.updated_by
+ FROM moderation_external_files mef
+ INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id
+ WHERE mef.sha1 = ANY($1)
+ "#,
+ &hash_bytes,
+ )
+ .fetch_all(pool)
+ .await?;
+
+ let license_ids = rows.iter().map(|row| row.id).collect::>();
+ let files_map = fetch_linked_files(pool, &license_ids).await?;
+
+ let mut results = HashMap::new();
+ for row in rows {
+ let hash = String::from_utf8(row.hash)
+ .unwrap_or_else(|err| hex::encode(err.into_bytes()));
+ let linked_files = files_map.get(&row.id).cloned().unwrap_or_default();
+ results.insert(
+ hash,
+ LicenseRow {
+ id: row.id,
+ title: row.title,
+ status: row.status,
+ link: row.link,
+ exceptions: row.exceptions,
+ proof: row.proof,
+ flame_project_id: row.flame_project_id,
+ inserted_at: row.inserted_at,
+ inserted_by: row.inserted_by,
+ updated_at: row.updated_at,
+ updated_by: row.updated_by,
+ }
+ .into_external_project(linked_files),
+ );
+ }
+
+ Ok(results)
+}
+
+async fn fetch_by_flame_ids(
+ pool: &PgPool,
+ flame_ids: &[i32],
+) -> Result>, ApiError> {
+ if flame_ids.is_empty() {
+ return Ok(HashMap::new());
+ }
+
+ let rows = sqlx::query_as!(
+ LicenseRow,
+ r#"
+ SELECT
+ mel.id,
+ mel.title,
+ mel.status,
+ mel.link,
+ mel.exceptions,
+ mel.proof,
+ mel.flame_project_id,
+ mel.inserted_at,
+ mel.inserted_by,
+ mel.updated_at,
+ mel.updated_by
+ FROM moderation_external_licenses mel
+ WHERE mel.flame_project_id = ANY($1)
+ ORDER BY mel.id
+ "#,
+ flame_ids,
+ )
+ .fetch_all(pool)
+ .await?;
+
+ let license_ids = rows.iter().map(|row| row.id).collect::>();
+ let files_map = fetch_linked_files(pool, &license_ids).await?;
+
+ let mut results: HashMap> = HashMap::new();
+ for row in rows {
+ if let Some(flame_project_id) = row.flame_project_id {
+ let linked_files =
+ files_map.get(&row.id).cloned().unwrap_or_default();
+ results
+ .entry(flame_project_id)
+ .or_default()
+ .push(row.into_external_project(linked_files));
+ }
+ }
+
+ Ok(results)
+}
+
#[utoipa::path]
#[post("/search")]
async fn search(
@@ -144,7 +347,8 @@ async fn search(
)
.await?;
- let rows = sqlx::query!(
+ let rows = sqlx::query_as!(
+ LicenseRow,
r#"
SELECT
mel.id,
@@ -160,11 +364,16 @@ async fn search(
mel.updated_by
FROM moderation_external_licenses mel
WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%')
- AND ($2::integer IS NULL OR mel.flame_project_id = $2)
+ AND (
+ ($2::integer IS NULL AND $3::integer[] IS NULL)
+ OR mel.flame_project_id = $2
+ OR mel.flame_project_id = ANY($3)
+ )
ORDER BY mel.id
"#,
body.title,
body.flame_id,
+ body.flame_ids.as_deref(),
)
.fetch_all(&**pool)
.await?;
@@ -177,26 +386,42 @@ async fn search(
.map(|row| {
let linked_files =
files_map.get(&row.id).cloned().unwrap_or_default();
- LicenseRow {
- id: row.id,
- title: row.title,
- status: row.status,
- link: row.link,
- exceptions: row.exceptions,
- proof: row.proof,
- flame_project_id: row.flame_project_id,
- inserted_at: row.inserted_at,
- inserted_by: row.inserted_by,
- updated_at: row.updated_at,
- updated_by: row.updated_by,
- }
- .into_external_project(linked_files)
+ row.into_external_project(linked_files)
})
.collect();
Ok(web::Json(results))
}
+#[utoipa::path]
+#[post("/lookup")]
+async fn lookup(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ body: web::Json,
+) -> Result, ApiError> {
+ check_is_moderator_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_READ,
+ )
+ .await?;
+
+ let body = body.into_inner();
+ let hashes = normalize_sha1_hashes(&body.hashes)?;
+ let flame_ids = fetch_by_flame_ids(&pool, &body.flame_ids).await?;
+ let hashes = fetch_by_hashes(&pool, &hashes).await?;
+
+ Ok(web::Json(ExternalLicenseLookupResponse {
+ flame_ids,
+ hashes,
+ }))
+}
+
#[utoipa::path]
#[get("/by-sha1/{sha1}")]
async fn get_by_sha1(
@@ -215,48 +440,145 @@ async fn get_by_sha1(
)
.await?;
- let sha1 = path.into_inner().0;
+ let hashes = normalize_sha1_hashes(&[path.into_inner().0])?;
+ let hash = hashes.first().ok_or(ApiError::NotFound)?;
+ let mut results = fetch_by_hashes(&pool, &hashes).await?;
+ let result = results.remove(hash).ok_or(ApiError::NotFound)?;
+
+ Ok(web::Json(result))
+}
+
+#[utoipa::path]
+#[post("/by-sha1")]
+async fn get_by_sha1_bulk(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ body: web::Json,
+) -> Result>, ApiError> {
+ check_is_moderator_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_READ,
+ )
+ .await?;
+
+ let hashes = normalize_sha1_hashes(&body.hashes)?;
+ let results = fetch_by_hashes(&pool, &hashes).await?;
+
+ Ok(web::Json(results))
+}
+
+#[utoipa::path]
+#[post("/file")]
+async fn add_file(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ body: web::Json,
+) -> Result, ApiError> {
+ upsert_file_license(req, pool, redis, session_queue, body).await
+}
+
+#[utoipa::path]
+#[post("/file/reassign")]
+async fn reassign_file(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ body: web::Json,
+) -> Result, ApiError> {
+ upsert_file_license(req, pool, redis, session_queue, body).await
+}
+
+async fn upsert_file_license(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ body: web::Json,
+) -> Result, ApiError> {
+ let user = check_is_moderator_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_READ,
+ )
+ .await?;
+
+ let body = body.into_inner();
+ let license_id = body.license_id.parse()?;
+ if body.hashes.is_empty() {
+ return Err(ApiError::InvalidInput(
+ "hashes must contain at least one SHA1 hex string".to_string(),
+ ));
+ }
+ let hashes = normalize_sha1_hashes(&body.hashes)?;
+ let hash_bytes = hashes
+ .iter()
+ .map(|hash| hash.as_bytes().to_vec())
+ .collect::>();
+ let filenames = vec![None; hashes.len()];
+ let license_ids = vec![license_id; hashes.len()];
+
+ let mut transaction = pool.begin().await?;
- let row = sqlx::query!(
+ let license = sqlx::query!(
r#"
SELECT
- mel.id,
- mel.title,
- mel.status,
- mel.link,
- mel.exceptions,
- mel.proof,
- mel.flame_project_id,
- mel.inserted_at,
- mel.inserted_by,
- mel.updated_at,
- mel.updated_by
- FROM moderation_external_files mef
- INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id
- WHERE mef.sha1 = $1
+ id,
+ title,
+ status,
+ link,
+ exceptions,
+ proof,
+ flame_project_id,
+ inserted_at,
+ inserted_by,
+ updated_at,
+ updated_by
+ FROM moderation_external_licenses
+ WHERE id = $1
"#,
- sha1.as_bytes().to_vec(),
+ license_id,
)
- .fetch_optional(&**pool)
+ .fetch_optional(&mut transaction)
.await?
.ok_or(ApiError::NotFound)?;
- let files_map = fetch_linked_files(&pool, &[row.id]).await?;
- let linked_files = files_map.get(&row.id).cloned().unwrap_or_default();
+ ExternalLicense::insert_files(
+ &mut transaction,
+ &hash_bytes,
+ &filenames,
+ &license_ids,
+ DBUserId(user.id.0 as i64),
+ )
+ .await?;
+
+ transaction.commit().await?;
+
+ let files_map = fetch_linked_files(&pool, &[license_id]).await?;
+ let linked_files = files_map.get(&license_id).cloned().unwrap_or_default();
Ok(web::Json(
LicenseRow {
- id: row.id,
- title: row.title,
- status: row.status,
- link: row.link,
- exceptions: row.exceptions,
- proof: row.proof,
- flame_project_id: row.flame_project_id,
- inserted_at: row.inserted_at,
- inserted_by: row.inserted_by,
- updated_at: row.updated_at,
- updated_by: row.updated_by,
+ id: license.id,
+ title: license.title,
+ status: license.status,
+ link: license.link,
+ exceptions: license.exceptions,
+ proof: license.proof,
+ flame_project_id: license.flame_project_id,
+ inserted_at: license.inserted_at,
+ inserted_by: license.inserted_by,
+ updated_at: license.updated_at,
+ updated_by: license.updated_by,
}
.into_external_project(linked_files),
))
diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs
index d201f081e1..491836e446 100644
--- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs
+++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs
@@ -289,13 +289,13 @@ async fn get_report(
'flag_reason', 'delphi',
'download_url', f.url,
-- TODO: replace with `json_array` in Postgres 16
- 'issues', (
- SELECT json_agg(
- to_jsonb(dri)
- || jsonb_build_object(
- -- TODO: replace with `json_array` in Postgres 16
- 'details', (
- SELECT coalesce(jsonb_agg(
+ 'issues', (
+ SELECT coalesce(json_agg(
+ to_jsonb(dri)
+ || jsonb_build_object(
+ -- TODO: replace with `json_array` in Postgres 16
+ 'details', (
+ SELECT coalesce(jsonb_agg(
jsonb_build_object(
'id', didws.id,
'issue_id', didws.issue_id,
@@ -310,11 +310,11 @@ async fn get_report(
FROM delphi_issue_details_with_statuses didws
WHERE didws.issue_id = dri.id
)
- )
- )
- FROM delphi_report_issues dri
- WHERE
- dri.report_id = dr.id
+ )
+ ), '[]'::json)
+ FROM delphi_report_issues dri
+ WHERE
+ dri.report_id = dr.id
-- see delphi.rs todo comment
AND dri.issue_type != '__dummy'
)
diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs
index dd14c32bdd..aa8e491229 100644
--- a/apps/labrinth/src/routes/v2/project_creation.rs
+++ b/apps/labrinth/src/routes/v2/project_creation.rs
@@ -20,7 +20,6 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
-use std::sync::Arc;
use validator::Validate;
use super::version_creation::InitialVersionData;
@@ -158,7 +157,7 @@ pub async fn project_create(
payload: Multipart,
client: Data,
redis: Data,
- file_host: Data>,
+ file_host: Data,
session_queue: Data,
http: Data,
) -> Result {
diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs
index ab24386b8e..46aa924162 100644
--- a/apps/labrinth/src/routes/v2/projects.rs
+++ b/apps/labrinth/src/routes/v2/projects.rs
@@ -18,7 +18,6 @@ use crate::search::{SearchBackend, SearchRequest};
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
-use std::sync::Arc;
use validator::Validate;
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
@@ -924,7 +923,7 @@ pub async fn project_icon_edit(
info: web::Path<(String,)>,
pool: web::Data,
redis: web::Data,
- file_host: web::Data>,
+ file_host: web::Data,
payload: web::Payload,
session_queue: web::Data,
) -> Result {
@@ -964,7 +963,7 @@ pub async fn delete_project_icon(
info: web::Path<(String,)>,
pool: web::Data,
redis: web::Data,
- file_host: web::Data>,
+ file_host: web::Data,
session_queue: web::Data,
) -> Result {
// Returns NoContent, so no need to convert
@@ -1055,7 +1054,7 @@ pub async fn add_gallery_item(
info: web::Path<(String,)>,
pool: web::Data,
redis: web::Data,
- file_host: web::Data>,
+ file_host: web::Data,
payload: web::Payload,
session_queue: web::Data,
) -> Result {
@@ -1198,7 +1197,7 @@ pub async fn delete_gallery_item(
web::Query(item): web::Query,
pool: web::Data,
redis: web::Data