diff --git a/Cargo.toml b/Cargo.toml
index 3e4da59280..966c02830a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -240,6 +240,7 @@ bool_to_int_with_if = "warn"
borrow_as_ptr = "warn"
cfg_not_test = "warn"
clear_with_drain = "warn"
+type_complexity = "allow"
cloned_instead_of_copied = "warn"
collection_is_never_read = "warn"
dbg_macro = "warn"
diff --git a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
index 44a50f083d..33a7dbf92e 100644
--- a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
+++ b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
@@ -24,7 +24,7 @@
-
diff --git a/apps/frontend/src/composables/featureFlags.ts b/apps/frontend/src/composables/featureFlags.ts
index 1becc8b856..c6ffe9eeec 100644
--- a/apps/frontend/src/composables/featureFlags.ts
+++ b/apps/frontend/src/composables/featureFlags.ts
@@ -48,7 +48,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
useV1ContentTabAPI: true,
labrinthApiCanary: false,
dismissedExternalProjectsInfo: false,
- modpackPermissionsPage: false,
showAllBanners: false,
alwaysIgnoreErrorBanner: false,
showViewProdRouteBanner: false,
diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json
index 34cb88792d..f248bac278 100644
--- a/apps/frontend/src/locales/en-US/index.json
+++ b/apps/frontend/src/locales/en-US/index.json
@@ -2874,7 +2874,7 @@
"message": "You're all set!"
},
"project.settings.permissions.fail.description": {
- "message": "You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content."
+ "message": "You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content or provide proof that you do have permission to use it."
},
"project.settings.permissions.fail.title": {
"message": "Some content can't be included"
@@ -2888,8 +2888,20 @@
"project.settings.permissions.learn-more": {
"message": "Learn more"
},
+ "project.settings.permissions.no-results": {
+ "message": "No external projects match your search."
+ },
"project.settings.permissions.search-placeholder": {
- "message": "Search {count} {count, plural, one {external project} other {external projects}}..."
+ "message": "Search {count} {count, plural, one {project} other {projects}}..."
+ },
+ "project.settings.permissions.sort.most-files": {
+ "message": "Most files"
+ },
+ "project.settings.permissions.sort.recently-edited": {
+ "message": "Recently edited"
+ },
+ "project.settings.permissions.sort.status": {
+ "message": "Status"
},
"project.settings.title": {
"message": "Settings"
diff --git a/apps/frontend/src/pages/[type]/[project]/settings.vue b/apps/frontend/src/pages/[type]/[project]/settings.vue
index 45ea37ca34..5a37f4a228 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings.vue
@@ -47,10 +47,8 @@ const navItems = computed(() => {
projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) &&
isStaff(currentMember.value?.user)
- const hasPermissionsPage = computed(
- () =>
- flags.value.modpackPermissionsPage &&
- projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
+ const hasPermissionsPage = computed(() =>
+ projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
)
const items = [
@@ -82,16 +80,16 @@ const navItems = computed(() => {
label: formatMessage(commonProjectSettingsMessages.description),
icon: AlignLeftIcon,
},
- hasPermissionsPage.value && {
- link: `/${base}/settings/permissions`,
- label: formatMessage(commonProjectSettingsMessages.permissions),
- icon: SignatureIcon,
- },
!isServerProject.value && {
link: `/${base}/settings/versions`,
label: formatMessage(commonProjectSettingsMessages.versions),
icon: VersionIcon,
},
+ hasPermissionsPage.value && {
+ link: `/${base}/settings/permissions`,
+ label: formatMessage(commonProjectSettingsMessages.permissions),
+ icon: SignatureIcon,
+ },
!isServerProject.value && {
link: `/${base}/settings/license`,
label: formatMessage(commonProjectSettingsMessages.license),
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/permissions.vue b/apps/frontend/src/pages/[type]/[project]/settings/permissions.vue
index 2d50a45c5d..7d6050d778 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/permissions.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/permissions.vue
@@ -1,43 +1,189 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
-
+
+
+
+
+
+
+
+
+
-
+
-
+
-
- {{ currentSortType }}
+
+ {{ currentSortLabel }}
-
+
+
+
+
+
+
+ {{ attributionError }}
+
+
+
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/versions.vue b/apps/frontend/src/pages/[type]/[project]/settings/versions.vue
index 4d1e11ebca..4fe35f27ed 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/versions.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/versions.vue
@@ -14,21 +14,22 @@
proceed-label="Delete"
@proceed="deleteVersion()"
/>
-
@@ -454,7 +455,9 @@ async function deleteVersion() {
stopLoading()
}
-const withheldVersions = computed(() => ['4.0.0'])
+const withheldVersions = computed(() =>
+ versions.value.filter((x) => x.files_missing_attribution?.length > 0),
+)
const messages = defineMessages({
withheldVersionsWarningTitle: {
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 83ae37da89..738d16a2a5 100644
--- a/apps/frontend/src/pages/report.vue
+++ b/apps/frontend/src/pages/report.vue
@@ -280,11 +280,11 @@ import {
VersionIcon,
XCircleIcon,
} from '@modrinth/assets'
-import { defineMessage } from '@modrinth/ui'
import {
AutoLink,
Avatar,
ButtonStyled,
+ defineMessage,
defineMessages,
injectNotificationManager,
IntlFormatted,
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-1d27a83fb85c4640c3fc88fc6caa8209973ced0f88d8dbecdac349bbe70930a5.json b/apps/labrinth/.sqlx/query-1d27a83fb85c4640c3fc88fc6caa8209973ced0f88d8dbecdac349bbe70930a5.json
new file mode 100644
index 0000000000..928f02a117
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-1d27a83fb85c4640c3fc88fc6caa8209973ced0f88d8dbecdac349bbe70930a5.json
@@ -0,0 +1,17 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)\n values ($1, $2, $3, $4)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Text",
+ "Bytea",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "1d27a83fb85c4640c3fc88fc6caa8209973ced0f88d8dbecdac349bbe70930a5"
+}
diff --git a/apps/labrinth/.sqlx/query-1d96df0ac64801641f9af796d482d2964c1acf2d03e15b8f1397340d0c994af6.json b/apps/labrinth/.sqlx/query-1d96df0ac64801641f9af796d482d2964c1acf2d03e15b8f1397340d0c994af6.json
new file mode 100644
index 0000000000..25146596a0
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-1d96df0ac64801641f9af796d482d2964c1acf2d03e15b8f1397340d0c994af6.json
@@ -0,0 +1,46 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tselect\n\t\t\tg.id as \"id: DBAttributionGroupId\",\n\t\t\tg.flame_project,\n\t\t\tg.attribution,\n\t\t\tg.attributed_at,\n\t\t\tg.attributed_by as \"attributed_by: i64\"\n\t\tfrom project_attribution_groups g\n\t\twhere g.project_id = $1\n\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id: DBAttributionGroupId",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "flame_project",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 2,
+ "name": "attribution",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 3,
+ "name": "attributed_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 4,
+ "name": "attributed_by: i64",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ true,
+ true,
+ true
+ ]
+ },
+ "hash": "1d96df0ac64801641f9af796d482d2964c1acf2d03e15b8f1397340d0c994af6"
+}
diff --git a/apps/labrinth/.sqlx/query-25d6977fa2db63f979fbe391f3475445fa92c9bbb5b034531f5e033a18c2a6a3.json b/apps/labrinth/.sqlx/query-25d6977fa2db63f979fbe391f3475445fa92c9bbb5b034531f5e033a18c2a6a3.json
new file mode 100644
index 0000000000..1fbd6879da
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-25d6977fa2db63f979fbe391f3475445fa92c9bbb5b034531f5e033a18c2a6a3.json
@@ -0,0 +1,88 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n mef.sha1 hash,\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id\n WHERE mef.sha1 = ANY($1)\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "hash",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 1,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 2,
+ "name": "title",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "status",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "link",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 5,
+ "name": "exceptions",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
+ "name": "proof",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 7,
+ "name": "flame_project_id",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 8,
+ "name": "inserted_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 9,
+ "name": "inserted_by",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 10,
+ "name": "updated_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 11,
+ "name": "updated_by",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "ByteaArray"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true,
+ false,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true
+ ]
+ },
+ "hash": "25d6977fa2db63f979fbe391f3475445fa92c9bbb5b034531f5e033a18c2a6a3"
+}
diff --git a/apps/labrinth/.sqlx/query-2e6bb84f45c0f8ba7bb9b09bf3def4322f727c7af8bd4be49dc4d7c487b925ed.json b/apps/labrinth/.sqlx/query-2e6bb84f45c0f8ba7bb9b09bf3def4322f727c7af8bd4be49dc4d7c487b925ed.json
new file mode 100644
index 0000000000..2c5ee6ec20
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-2e6bb84f45c0f8ba7bb9b09bf3def4322f727c7af8bd4be49dc4d7c487b925ed.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n insert into project_attribution_files (group_id, name, sha1)\n select $1, unnest($2::text[]), unnest($3::bytea[])\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "TextArray",
+ "ByteaArray"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "2e6bb84f45c0f8ba7bb9b09bf3def4322f727c7af8bd4be49dc4d7c487b925ed"
+}
diff --git a/apps/labrinth/.sqlx/query-2f56a06b78a810936a77547ab5890943d6aa3e9143a8e2617d025be960880223.json b/apps/labrinth/.sqlx/query-2f56a06b78a810936a77547ab5890943d6aa3e9143a8e2617d025be960880223.json
new file mode 100644
index 0000000000..202c1c4e6d
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-2f56a06b78a810936a77547ab5890943d6aa3e9143a8e2617d025be960880223.json
@@ -0,0 +1,40 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT mel.id, mel.flame_project_id, mel.status status, mel.link\n FROM moderation_external_licenses mel\n WHERE mel.flame_project_id = ANY($1)\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "flame_project_id",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 2,
+ "name": "status",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "link",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int4Array"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ false,
+ true
+ ]
+ },
+ "hash": "2f56a06b78a810936a77547ab5890943d6aa3e9143a8e2617d025be960880223"
+}
diff --git a/apps/labrinth/.sqlx/query-301959b1ec20a4925f86188bc5e1c0cdd11f0d15004ce9f91fbeefcc3a27f8f4.json b/apps/labrinth/.sqlx/query-301959b1ec20a4925f86188bc5e1c0cdd11f0d15004ce9f91fbeefcc3a27f8f4.json
new file mode 100644
index 0000000000..80c3a445f6
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-301959b1ec20a4925f86188bc5e1c0cdd11f0d15004ce9f91fbeefcc3a27f8f4.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n DELETE FROM project_attribution_groups g\n WHERE NOT EXISTS (\n SELECT 1\n FROM project_attribution_files paf\n INNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1\n WHERE paf.group_id = g.id\n )\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": []
+ },
+ "hash": "301959b1ec20a4925f86188bc5e1c0cdd11f0d15004ce9f91fbeefcc3a27f8f4"
+}
diff --git a/apps/labrinth/.sqlx/query-424b975139004d2854d9365ef950984dd1a65e333bb80924b5883df5e7d3cad2.json b/apps/labrinth/.sqlx/query-424b975139004d2854d9365ef950984dd1a65e333bb80924b5883df5e7d3cad2.json
new file mode 100644
index 0000000000..c427ada77e
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-424b975139004d2854d9365ef950984dd1a65e333bb80924b5883df5e7d3cad2.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n select id as \"id: DBAttributionGroupId\", flame_project\n from project_attribution_groups\n where project_id = $1 and flame_project is not null\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id: DBAttributionGroupId",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "flame_project",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ true
+ ]
+ },
+ "hash": "424b975139004d2854d9365ef950984dd1a65e333bb80924b5883df5e7d3cad2"
+}
diff --git a/apps/labrinth/.sqlx/query-46e1f1512ff148b532fb382b51b6a5a4a81d3e8bc17886c75bc574cd5662c49f.json b/apps/labrinth/.sqlx/query-46e1f1512ff148b532fb382b51b6a5a4a81d3e8bc17886c75bc574cd5662c49f.json
new file mode 100644
index 0000000000..721cea8be6
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-46e1f1512ff148b532fb382b51b6a5a4a81d3e8bc17886c75bc574cd5662c49f.json
@@ -0,0 +1,46 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n select\n d.dependent_id as \"version_id: DBVersionId\",\n d.dependency_file_name as \"file_name!\",\n pag.attribution,\n pag.flame_project,\n pag.project_id as \"project_id: DBProjectId\"\n from dependencies d\n inner join files f on f.version_id = d.dependent_id\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join override_file_sources ofs on ofs.file_id = f.id\n inner join project_attribution_files paf on paf.sha1 = ofs.sha1\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where d.dependent_id = ANY($1)\n and d.dependency_file_name is not null\n and pag.attribution is not null\n and pag.attribution->>'kind' not in ('no_permission')\n and (\n pag.attribution->'moderation_status' is null\n or pag.attribution->'moderation_status'->>'kind' = 'approved'\n )\n and split_part(paf.name, '/', -1) = d.dependency_file_name\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "version_id: DBVersionId",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "file_name!",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "attribution",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 3,
+ "name": "flame_project",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 4,
+ "name": "project_id: DBProjectId",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ true,
+ true,
+ false
+ ]
+ },
+ "hash": "46e1f1512ff148b532fb382b51b6a5a4a81d3e8bc17886c75bc574cd5662c49f"
+}
diff --git a/apps/labrinth/.sqlx/query-4fb69c674dca723b6b2cb7bea07d51feb43b2714c1cd6a2987a6fe1f10e2579a.json b/apps/labrinth/.sqlx/query-4fb69c674dca723b6b2cb7bea07d51feb43b2714c1cd6a2987a6fe1f10e2579a.json
new file mode 100644
index 0000000000..76174d70dd
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-4fb69c674dca723b6b2cb7bea07d51feb43b2714c1cd6a2987a6fe1f10e2579a.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tupdate project_attribution_files\n\t\tset group_id = $1\n\t\twhere sha1 = $2\n\t\tand group_id in (\n\t\t\tselect id from project_attribution_groups where project_id = $3\n\t\t)\n\t\t",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Bytea",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "4fb69c674dca723b6b2cb7bea07d51feb43b2714c1cd6a2987a6fe1f10e2579a"
+}
diff --git a/apps/labrinth/.sqlx/query-5f7b9d628d3ff0addc12cfaab68d7cbcca08a89aedeb15ce5926ed87ed8a12c6.json b/apps/labrinth/.sqlx/query-5f7b9d628d3ff0addc12cfaab68d7cbcca08a89aedeb15ce5926ed87ed8a12c6.json
new file mode 100644
index 0000000000..e71a423986
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-5f7b9d628d3ff0addc12cfaab68d7cbcca08a89aedeb15ce5926ed87ed8a12c6.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tupdate project_attribution_files\n\t\tset group_id = $1\n\t\twhere sha1 = $2 and group_id = $3\n\t\t",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Bytea",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "5f7b9d628d3ff0addc12cfaab68d7cbcca08a89aedeb15ce5926ed87ed8a12c6"
+}
diff --git a/apps/labrinth/.sqlx/query-5f847946ce63d3773b9a5822971ee7acaafeed018ce0540b2826bd878f213b87.json b/apps/labrinth/.sqlx/query-5f847946ce63d3773b9a5822971ee7acaafeed018ce0540b2826bd878f213b87.json
new file mode 100644
index 0000000000..a219d084e8
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-5f847946ce63d3773b9a5822971ee7acaafeed018ce0540b2826bd878f213b87.json
@@ -0,0 +1,82 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n id,\n title,\n status,\n link,\n exceptions,\n proof,\n flame_project_id,\n inserted_at,\n inserted_by,\n updated_at,\n updated_by\n FROM moderation_external_licenses\n WHERE id = $1\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "title",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "status",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "link",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "exceptions",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 5,
+ "name": "proof",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
+ "name": "flame_project_id",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 7,
+ "name": "inserted_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 8,
+ "name": "inserted_by",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 9,
+ "name": "updated_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 10,
+ "name": "updated_by",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ false,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true
+ ]
+ },
+ "hash": "5f847946ce63d3773b9a5822971ee7acaafeed018ce0540b2826bd878f213b87"
+}
diff --git a/apps/labrinth/.sqlx/query-6320df0491556027ee64fd031797b252c2145ecf179e9ea9a8cedbdc8b7ed622.json b/apps/labrinth/.sqlx/query-6320df0491556027ee64fd031797b252c2145ecf179e9ea9a8cedbdc8b7ed622.json
new file mode 100644
index 0000000000..297814d5ad
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-6320df0491556027ee64fd031797b252c2145ecf179e9ea9a8cedbdc8b7ed622.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tinsert into project_attribution_groups (id, project_id)\n\t\tvalues ($1, $2)\n\t\t",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "6320df0491556027ee64fd031797b252c2145ecf179e9ea9a8cedbdc8b7ed622"
+}
diff --git a/apps/labrinth/.sqlx/query-64c6abc464f2df37372875817ceb67b0e33786fe3dd27c052311356d43b1d601.json b/apps/labrinth/.sqlx/query-64c6abc464f2df37372875817ceb67b0e33786fe3dd27c052311356d43b1d601.json
new file mode 100644
index 0000000000..ee0f3478d5
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-64c6abc464f2df37372875817ceb67b0e33786fe3dd27c052311356d43b1d601.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n INSERT INTO file_scans (file_id)\n VALUES ($1)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "64c6abc464f2df37372875817ceb67b0e33786fe3dd27c052311356d43b1d601"
+}
diff --git a/apps/labrinth/.sqlx/query-73f7542b4b6738c4baf1e2a3513645c3bf07a960b7b9fac529f23099675ec0cc.json b/apps/labrinth/.sqlx/query-73f7542b4b6738c4baf1e2a3513645c3bf07a960b7b9fac529f23099675ec0cc.json
new file mode 100644
index 0000000000..1aa6170dcc
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-73f7542b4b6738c4baf1e2a3513645c3bf07a960b7b9fac529f23099675ec0cc.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tselect exists(\n\t\t\tselect 1 from project_attribution_groups where id = $1 and project_id = $2\n\t\t) as \"exists!\"\n\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "exists!",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "73f7542b4b6738c4baf1e2a3513645c3bf07a960b7b9fac529f23099675ec0cc"
+}
diff --git a/apps/labrinth/.sqlx/query-80751dbca09a9dcb469a30fe5b8225e520baf50750a78875f582349610439a55.json b/apps/labrinth/.sqlx/query-80751dbca09a9dcb469a30fe5b8225e520baf50750a78875f582349610439a55.json
new file mode 100644
index 0000000000..b8fc60acc8
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-80751dbca09a9dcb469a30fe5b8225e520baf50750a78875f582349610439a55.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n insert into file_scans (file_id, attributions_scanned_at)\n values ($1, now())\n on conflict (file_id) do update set attributions_scanned_at = now()\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "80751dbca09a9dcb469a30fe5b8225e520baf50750a78875f582349610439a55"
+}
diff --git a/apps/labrinth/.sqlx/query-8820a5985291c159c98371c9650092e3eba21c81e3b3386be779978aff30451a.json b/apps/labrinth/.sqlx/query-8820a5985291c159c98371c9650092e3eba21c81e3b3386be779978aff30451a.json
deleted file mode 100644
index 18d5cf1b83..0000000000
--- a/apps/labrinth/.sqlx/query-8820a5985291c159c98371c9650092e3eba21c81e3b3386be779978aff30451a.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n 'issues', (\n SELECT json_agg(\n to_jsonb(dri)\n || jsonb_build_object(\n -- TODO: replace with `json_array` in Postgres 16\n 'details', (\n SELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n )\n )\n FROM delphi_report_issues dri\n WHERE\n dri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "data!: sqlx::types::Json",
- "type_info": "Jsonb"
- }
- ],
- "parameters": {
- "Left": [
- "Int8"
- ]
- },
- "nullable": [
- null
- ]
- },
- "hash": "8820a5985291c159c98371c9650092e3eba21c81e3b3386be779978aff30451a"
-}
diff --git a/apps/labrinth/.sqlx/query-8ffcb0c2bd82eaf64b143745adab359db6fcc448d6306292a7b345277a075883.json b/apps/labrinth/.sqlx/query-8ffcb0c2bd82eaf64b143745adab359db6fcc448d6306292a7b345277a075883.json
new file mode 100644
index 0000000000..3da4278f04
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-8ffcb0c2bd82eaf64b143745adab359db6fcc448d6306292a7b345277a075883.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT EXISTS(SELECT 1 FROM project_attribution_groups WHERE id=$1)",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "exists",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "8ffcb0c2bd82eaf64b143745adab359db6fcc448d6306292a7b345277a075883"
+}
diff --git a/apps/labrinth/.sqlx/query-944286604bdef4ee40f79af358d1b68cc93bfd2067619d9cc1d03013f73e5424.json b/apps/labrinth/.sqlx/query-944286604bdef4ee40f79af358d1b68cc93bfd2067619d9cc1d03013f73e5424.json
new file mode 100644
index 0000000000..06e8fd3198
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-944286604bdef4ee40f79af358d1b68cc93bfd2067619d9cc1d03013f73e5424.json
@@ -0,0 +1,82 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\t\t\tselect\n\t\t\t\t\tid,\n\t\t\t\t\ttitle,\n\t\t\t\t\tstatus,\n\t\t\t\t\tlink,\n\t\t\t\t\texceptions,\n\t\t\t\t\tproof,\n\t\t\t\t\tflame_project_id,\n\t\t\t\t\tinserted_at,\n\t\t\t\t\tinserted_by,\n\t\t\t\t\tupdated_at,\n\t\t\t\t\tupdated_by\n\t\t\t\tfrom moderation_external_licenses\n\t\t\t\twhere id = ANY($1)\n\t\t\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "title",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "status",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "link",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "exceptions",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 5,
+ "name": "proof",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
+ "name": "flame_project_id",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 7,
+ "name": "inserted_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 8,
+ "name": "inserted_by",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 9,
+ "name": "updated_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 10,
+ "name": "updated_by",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ false,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true
+ ]
+ },
+ "hash": "944286604bdef4ee40f79af358d1b68cc93bfd2067619d9cc1d03013f73e5424"
+}
diff --git a/apps/labrinth/.sqlx/query-94920d66b27503c84744d41cb2b44dd213bb461596046488eaab0b62b0fe83c5.json b/apps/labrinth/.sqlx/query-94920d66b27503c84744d41cb2b44dd213bb461596046488eaab0b62b0fe83c5.json
new file mode 100644
index 0000000000..7b1815f835
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-94920d66b27503c84744d41cb2b44dd213bb461596046488eaab0b62b0fe83c5.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tupdate project_attribution_groups\n\t\tset attribution = $1, attributed_at = now(), attributed_by = $3\n\t\twhere id = $2\n\t\t",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Jsonb",
+ "Int8",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "94920d66b27503c84744d41cb2b44dd213bb461596046488eaab0b62b0fe83c5"
+}
diff --git a/apps/labrinth/.sqlx/query-95750cd616b72347fb997c3b02a3d69f13be5993c1fd2e302761089c499fb015.json b/apps/labrinth/.sqlx/query-95750cd616b72347fb997c3b02a3d69f13be5993c1fd2e302761089c499fb015.json
new file mode 100644
index 0000000000..07c051f897
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-95750cd616b72347fb997c3b02a3d69f13be5993c1fd2e302761089c499fb015.json
@@ -0,0 +1,17 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)\n values ($1, $2, $3, $4)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Text",
+ "Bytea",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "95750cd616b72347fb997c3b02a3d69f13be5993c1fd2e302761089c499fb015"
+}
diff --git a/apps/labrinth/.sqlx/query-968904f577c2c696c6222e19cc145bce0e845f2ab9f8629b0789a4861bddb4dc.json b/apps/labrinth/.sqlx/query-968904f577c2c696c6222e19cc145bce0e845f2ab9f8629b0789a4861bddb4dc.json
new file mode 100644
index 0000000000..1a588c429d
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-968904f577c2c696c6222e19cc145bce0e845f2ab9f8629b0789a4861bddb4dc.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n update file_scans\n set attributions_scanned_at = now\n from unnest($1::bigint[], $2::timestamptz[]) as u(id, now)\n where file_scans.file_id = u.id\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8Array",
+ "TimestamptzArray"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "968904f577c2c696c6222e19cc145bce0e845f2ab9f8629b0789a4861bddb4dc"
+}
diff --git a/apps/labrinth/.sqlx/query-b471add519874a6364b7a9cdbfa3a8e6f5a8a1ac9a7bacbce378f7995eb48664.json b/apps/labrinth/.sqlx/query-b471add519874a6364b7a9cdbfa3a8e6f5a8a1ac9a7bacbce378f7995eb48664.json
new file mode 100644
index 0000000000..e4cde72b9a
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-b471add519874a6364b7a9cdbfa3a8e6f5a8a1ac9a7bacbce378f7995eb48664.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n insert into project_attribution_groups (id, project_id)\n values ($1, $2)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "b471add519874a6364b7a9cdbfa3a8e6f5a8a1ac9a7bacbce378f7995eb48664"
+}
diff --git a/apps/labrinth/.sqlx/query-b782ed64e0df1e83623c8a8e55c00716f5338c85b6dfbb15c3465ff0cec339a7.json b/apps/labrinth/.sqlx/query-b782ed64e0df1e83623c8a8e55c00716f5338c85b6dfbb15c3465ff0cec339a7.json
new file mode 100644
index 0000000000..af1927f767
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-b782ed64e0df1e83623c8a8e55c00716f5338c85b6dfbb15c3465ff0cec339a7.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tselect paf.group_id\n\t\tfrom project_attribution_files paf\n\t\tinner join project_attribution_groups pag on pag.id = paf.group_id\n\t\twhere paf.sha1 = $1 and pag.project_id = $2\n\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "group_id",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "b782ed64e0df1e83623c8a8e55c00716f5338c85b6dfbb15c3465ff0cec339a7"
+}
diff --git a/apps/labrinth/.sqlx/query-bb7c239e2b424557f260c56c01e15ab8b0ec04d76a55a50888d28e6c5d3948bc.json b/apps/labrinth/.sqlx/query-bb7c239e2b424557f260c56c01e15ab8b0ec04d76a55a50888d28e6c5d3948bc.json
new file mode 100644
index 0000000000..316779bfde
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-bb7c239e2b424557f260c56c01e15ab8b0ec04d76a55a50888d28e6c5d3948bc.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tselect attribution\n\t\tfrom project_attribution_groups\n\t\twhere id = $1\n\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "attribution",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": [
+ true
+ ]
+ },
+ "hash": "bb7c239e2b424557f260c56c01e15ab8b0ec04d76a55a50888d28e6c5d3948bc"
+}
diff --git a/apps/labrinth/.sqlx/query-c39e1f0d820101ac1b19a3849254b03259f38f3de9ddca32e6189a7e015dd853.json b/apps/labrinth/.sqlx/query-c39e1f0d820101ac1b19a3849254b03259f38f3de9ddca32e6189a7e015dd853.json
new file mode 100644
index 0000000000..19a77c556c
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-c39e1f0d820101ac1b19a3849254b03259f38f3de9ddca32e6189a7e015dd853.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n insert into project_attribution_files (group_id, name, sha1)\n values ($1, $2, $3)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Text",
+ "Bytea"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "c39e1f0d820101ac1b19a3849254b03259f38f3de9ddca32e6189a7e015dd853"
+}
diff --git a/apps/labrinth/.sqlx/query-ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa.json b/apps/labrinth/.sqlx/query-ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa.json
new file mode 100644
index 0000000000..9d4ffdf994
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa.json
@@ -0,0 +1,29 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tselect paf.group_id, paf.name from project_attribution_files paf\n\t\tinner join project_attribution_groups pag on pag.id = paf.group_id\n\t\twhere paf.sha1 = $1 and pag.project_id = $2\n\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "group_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "name",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa"
+}
diff --git a/apps/labrinth/.sqlx/query-d9cd479a00fa1bdef23ad3ed781e0ab992b0305f07d5194a9166e5babb1dd44a.json b/apps/labrinth/.sqlx/query-d9cd479a00fa1bdef23ad3ed781e0ab992b0305f07d5194a9166e5babb1dd44a.json
new file mode 100644
index 0000000000..fe31b3371b
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-d9cd479a00fa1bdef23ad3ed781e0ab992b0305f07d5194a9166e5babb1dd44a.json
@@ -0,0 +1,34 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n select distinct f.version_id as \"version_id: DBVersionId\", f.id as \"file_id: DBFileId\",\n pag.flame_project\n from files f\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join override_file_sources ofs on ofs.file_id = f.id\n inner join project_attribution_files paf on paf.sha1 = ofs.sha1\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where f.version_id = ANY($1)\n and (\n pag.attribution is null\n or pag.attribution->>'kind' = 'no_permission'\n or (\n pag.attribution->'moderation_status' is not null\n and pag.attribution->'moderation_status'->>'kind' != 'approved'\n )\n )\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "version_id: DBVersionId",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "file_id: DBFileId",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 2,
+ "name": "flame_project",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true
+ ]
+ },
+ "hash": "d9cd479a00fa1bdef23ad3ed781e0ab992b0305f07d5194a9166e5babb1dd44a"
+}
diff --git a/apps/labrinth/.sqlx/query-dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9.json b/apps/labrinth/.sqlx/query-dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9.json
new file mode 100644
index 0000000000..8565cb6fbd
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9.json
@@ -0,0 +1,40 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\t\tselect id, name, version_number, date_published\n\t\t\tfrom versions\n\t\t\twhere id = ANY($1)\n\t\t\torder by date_published desc\n\t\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "name",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "version_number",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "date_published",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9"
+}
diff --git a/apps/labrinth/.sqlx/query-df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375.json b/apps/labrinth/.sqlx/query-df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375.json
new file mode 100644
index 0000000000..2456b3554f
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375.json
@@ -0,0 +1,17 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n insert into project_attribution_groups (id, project_id, attribution, flame_project)\n values ($1, $2, $3, $4)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Int8",
+ "Jsonb",
+ "Jsonb"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375"
+}
diff --git a/apps/labrinth/.sqlx/query-e4b58fe6e5d19ded48ab9e457b4e96fb51c8f74a7532bb9ab2e7a945b95bbe78.json b/apps/labrinth/.sqlx/query-e4b58fe6e5d19ded48ab9e457b4e96fb51c8f74a7532bb9ab2e7a945b95bbe78.json
new file mode 100644
index 0000000000..91a7a9c5c8
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-e4b58fe6e5d19ded48ab9e457b4e96fb51c8f74a7532bb9ab2e7a945b95bbe78.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n select paf.sha1 from project_attribution_files paf\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where pag.project_id = $1 and paf.sha1 = ANY($2)\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "sha1",
+ "type_info": "Bytea"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "ByteaArray"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "e4b58fe6e5d19ded48ab9e457b4e96fb51c8f74a7532bb9ab2e7a945b95bbe78"
+}
diff --git a/apps/labrinth/.sqlx/query-e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb.json b/apps/labrinth/.sqlx/query-e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb.json
new file mode 100644
index 0000000000..0b7cf69e9c
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n insert into override_file_sources (sha1, file_id)\n select unnest($1::bytea[]), $2\n on conflict do nothing\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "ByteaArray",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb"
+}
diff --git a/apps/labrinth/.sqlx/query-ed027c5571c34ff4fd1ac50e96f257b0055c9497d2699927b34829490e7a2529.json b/apps/labrinth/.sqlx/query-ed027c5571c34ff4fd1ac50e96f257b0055c9497d2699927b34829490e7a2529.json
new file mode 100644
index 0000000000..1b14566eb0
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-ed027c5571c34ff4fd1ac50e96f257b0055c9497d2699927b34829490e7a2529.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tdelete from project_attribution_groups g\n\t\twhere not exists (\n\t\t\tselect 1 from project_attribution_files f where f.group_id = g.id\n\t\t)\n\t\t",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": []
+ },
+ "hash": "ed027c5571c34ff4fd1ac50e96f257b0055c9497d2699927b34829490e7a2529"
+}
diff --git a/apps/labrinth/.sqlx/query-f9131eb55490b96851ac3760e3199d2fd2dbb3d212cb2b8687dda8ba0fad2a80.json b/apps/labrinth/.sqlx/query-f9131eb55490b96851ac3760e3199d2fd2dbb3d212cb2b8687dda8ba0fad2a80.json
new file mode 100644
index 0000000000..0ccd35ecd7
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-f9131eb55490b96851ac3760e3199d2fd2dbb3d212cb2b8687dda8ba0fad2a80.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n insert into project_attribution_groups (id, project_id, flame_project)\n values ($1, $2, $3)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Int8",
+ "Jsonb"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "f9131eb55490b96851ac3760e3199d2fd2dbb3d212cb2b8687dda8ba0fad2a80"
+}
diff --git a/apps/labrinth/.sqlx/query-f9b96c70c83f1bf0112243602843abae6ddc8851fc623ccf0a23f87567763485.json b/apps/labrinth/.sqlx/query-f9b96c70c83f1bf0112243602843abae6ddc8851fc623ccf0a23f87567763485.json
new file mode 100644
index 0000000000..17bdd2c3af
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-f9b96c70c83f1bf0112243602843abae6ddc8851fc623ccf0a23f87567763485.json
@@ -0,0 +1,40 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT encode(mef.sha1, 'escape') sha1, mel.id, mel.status status, mel.link\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id\n WHERE mef.sha1 = ANY($1)\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "sha1",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 1,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 2,
+ "name": "status",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "link",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "ByteaArray"
+ ]
+ },
+ "nullable": [
+ null,
+ false,
+ false,
+ true
+ ]
+ },
+ "hash": "f9b96c70c83f1bf0112243602843abae6ddc8851fc623ccf0a23f87567763485"
+}
diff --git a/apps/labrinth/.sqlx/query-fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6.json b/apps/labrinth/.sqlx/query-fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6.json
new file mode 100644
index 0000000000..99a5a8243f
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t'issues', (\n\t\t\t\t\tSELECT coalesce(json_agg(\n\t\t\t\t\t\tto_jsonb(dri)\n\t\t\t\t\t\t|| jsonb_build_object(\n\t\t\t\t\t\t\t-- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t\t\t\t'details', (\n\t\t\t\t\t\t\t\tSELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n\t\t\t\t\t\t)\n\t\t\t\t\t), '[]'::json)\n\t\t\t\t\tFROM delphi_report_issues dri\n\t\t\t\t\tWHERE\n\t\t\t\t\t\tdri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "data!: sqlx::types::Json",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6"
+}
diff --git a/apps/labrinth/AGENTS.md~HEAD b/apps/labrinth/AGENTS.md~HEAD
new file mode 100644
index 0000000000..0b73458d53
--- /dev/null
+++ b/apps/labrinth/AGENTS.md~HEAD
@@ -0,0 +1,34 @@
+# Labrinth
+
+Labrinth is the backend API service for Modrinth, written in Rust.
+
+## Code style
+
+- When writing `sqlx` queries, NEVER use `query` directly. Always prefer using the `query!`, `query_as!`, `query_scalar!` macros.
+
+## Pre-PR Checks
+
+When the user refers to "perform[ing] pre-PR checks", do the following:
+
+- Run `cargo clippy -p labrinth --all-targets` — there must be ZERO warnings, otherwise CI will fail
+- DO NOT run tests unless explicitly requested (they take a long time)
+- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare -- --tests`
+ - NEVER run `cargo sqlx prepare --workspace`
+
+## Testing
+
+- Run `cargo test -p labrinth --all-targets` to test your changes — all tests must pass
+
+## Local Services
+
+- Read the root `docker-compose.yml` to see what running services are available while developing
+- Use `docker exec` to access these services
+
+### Clickhouse
+
+- Access: `docker exec labrinth-clickhouse clickhouse-client`
+- Database: `staging_ariadne`
+
+### Postgres
+
+- Access: `docker exec labrinth-postgres psql -U labrinth -d labrinth -c ""`
diff --git a/apps/labrinth/migrations/20260423114534_project_attribution.sql b/apps/labrinth/migrations/20260423114534_project_attribution.sql
new file mode 100644
index 0000000000..805d85d870
--- /dev/null
+++ b/apps/labrinth/migrations/20260423114534_project_attribution.sql
@@ -0,0 +1,33 @@
+create table file_scans (
+ file_id bigint primary key references files(id),
+ -- if a file..
+ -- - does not have a row
+ -- -> was created before attributions system
+ -- - has a row, but `attributions_scanned_at = null`
+ -- -> still needs to be scanned
+ -- - has a row, and `attributions_scanned_at` is not null
+ -- -> attributions have been scanned
+ attributions_scanned_at timestamptz
+);
+
+create table project_attribution_groups (
+ id bigint primary key,
+ project_id bigint not null references mods(id),
+ flame_project jsonb,
+ attribution jsonb,
+ attributed_at timestamptz,
+ attributed_by bigint references users(id)
+);
+create index on project_attribution_groups (project_id);
+
+create table project_attribution_files (
+ group_id bigint not null references project_attribution_groups(id),
+ name text not null,
+ sha1 bytea not null
+);
+
+create table override_file_sources (
+ sha1 bytea not null,
+ file_id bigint not null references files(id),
+ primary key (sha1, file_id)
+);
diff --git a/apps/labrinth/migrations/20260519143157_fix_file_attribution_deletion.sql b/apps/labrinth/migrations/20260519143157_fix_file_attribution_deletion.sql
new file mode 100644
index 0000000000..473dbda6e9
--- /dev/null
+++ b/apps/labrinth/migrations/20260519143157_fix_file_attribution_deletion.sql
@@ -0,0 +1,19 @@
+alter table file_scans
+ drop constraint file_scans_file_id_fkey,
+ add constraint file_scans_file_id_fkey
+ foreign key (file_id) references files(id) on delete cascade;
+
+alter table project_attribution_groups
+ drop constraint project_attribution_groups_project_id_fkey,
+ add constraint project_attribution_groups_project_id_fkey
+ foreign key (project_id) references mods(id) on delete cascade;
+
+alter table project_attribution_files
+ drop constraint project_attribution_files_group_id_fkey,
+ add constraint project_attribution_files_group_id_fkey
+ foreign key (group_id) references project_attribution_groups(id) on delete cascade;
+
+alter table override_file_sources
+ drop constraint override_file_sources_file_id_fkey,
+ add constraint override_file_sources_file_id_fkey
+ foreign key (file_id) references files(id) on delete cascade;
diff --git a/apps/labrinth/migrations/20260521120000_project_attribution_external_license.sql b/apps/labrinth/migrations/20260521120000_project_attribution_external_license.sql
new file mode 100644
index 0000000000..5820106844
--- /dev/null
+++ b/apps/labrinth/migrations/20260521120000_project_attribution_external_license.sql
@@ -0,0 +1,20 @@
+alter table project_attribution_files
+ add column moderation_external_license_id bigint references moderation_external_licenses(id);
+
+create table version_attribution_exemptions (
+ version_id bigint primary key references versions(id) on delete cascade
+);
+
+create view attribution_enforced_versions as
+select v.id
+from versions v
+left join version_attribution_exemptions vae on vae.version_id = v.id
+where vae.version_id is null;
+
+-- grandfathering migration:
+-- insert into version_attribution_exemptions (version_id)
+-- select v.id
+-- from versions v
+-- inner join mods m on m.id = v.mod_id
+-- where m.status in ('approved', 'unlisted', 'archived', 'private', 'scheduled', 'withheld')
+-- on conflict do nothing;
diff --git a/apps/labrinth/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs
index 329e071042..f8a2bc24a8 100644
--- a/apps/labrinth/src/auth/checks.rs
+++ b/apps/labrinth/src/auth/checks.rs
@@ -5,11 +5,49 @@ use crate::database::models::version_item::VersionQueryResult;
use crate::database::models::{DBCollection, DBOrganization, DBTeamMember};
use crate::database::redis::RedisPool;
use crate::database::{DBProject, DBVersion, models};
+use crate::models::ids::FileId;
+use crate::models::projects::{
+ DependencyAttribution, MissingAttributionFile, OverrideSource, Version,
+};
use crate::models::users::User;
+use crate::queue::file_scan::{
+ DependencyAttributionData, get_dependency_attributions,
+ get_files_missing_attribution,
+};
use crate::routes::ApiError;
use futures::TryStreamExt;
use itertools::Itertools;
+pub fn enrich_dependency_attributions(
+ version: &mut Version,
+ dep_attr: &std::collections::HashMap<
+ (database::models::ids::DBVersionId, String),
+ DependencyAttributionData,
+ >,
+) {
+ let version_id = database::models::ids::DBVersionId(version.id.0 as i64);
+ for dep in &mut version.dependencies {
+ if let Some(file_name) = &dep.file_name
+ && let Some(attr) = dep_attr.get(&(version_id, file_name.clone()))
+ {
+ let attribution = DependencyAttribution {
+ link: attr.link.clone().and_then(|u| u.parse().ok()),
+ icon_url: attr.icon_url.clone().and_then(|u| u.parse().ok()),
+ license: attr
+ .license
+ .clone()
+ .and_then(|v| serde_json::from_value(v).ok()),
+ };
+ if attribution.link.is_some()
+ || attribution.icon_url.is_some()
+ || attribution.license.is_some()
+ {
+ dep.attribution = Some(attribution);
+ }
+ }
+ }
+}
+
pub trait ValidateAuthorized {
fn validate_authorized(
&self,
@@ -204,7 +242,45 @@ pub async fn filter_visible_versions(
)
.await?;
versions.retain(|x| filtered_version_ids.contains(&x.inner.id));
- Ok(versions.into_iter().map(|x| x.into()).collect())
+
+ let version_ids: Vec<_> = versions.iter().map(|v| v.inner.id).collect();
+ let missing = get_files_missing_attribution(pool, &version_ids)
+ .await
+ .unwrap_or_default();
+
+ let dep_attr = get_dependency_attributions(pool, &version_ids)
+ .await
+ .unwrap_or_default();
+
+ Ok(versions
+ .into_iter()
+ .map(|v| {
+ let files_missing = missing
+ .get(&v.inner.id)
+ .map(|entries| {
+ entries
+ .iter()
+ .map(|(id, fp)| MissingAttributionFile {
+ id: FileId(id.0 as u64),
+ override_source: fp
+ .as_ref()
+ .map(|p| OverrideSource::Flame {
+ id: p.id,
+ title: p.title.clone(),
+ url: p.url.clone(),
+ icon_url: p.icon_url.clone(),
+ })
+ .or(Some(OverrideSource::Unknown)),
+ })
+ .collect::>()
+ })
+ .unwrap_or_default();
+ let mut version = Version::from(v);
+ version.files_missing_attribution = files_missing;
+ enrich_dependency_attributions(&mut version, &dep_attr);
+ version
+ })
+ .collect())
}
impl ValidateAuthorized for models::DBOAuthClient {
@@ -258,13 +334,20 @@ pub async fn filter_visible_version_ids(
filter_enlisted_version_ids(versions.clone(), user_option, pool, redis)
.await?;
+ let version_ids: Vec<_> = versions.iter().map(|v| v.id).collect();
+ let withheld_versions = get_files_missing_attribution(pool, &version_ids)
+ .await
+ .unwrap_or_default();
+
// Return versions that are not hidden, we are a mod of, or we are enlisted on the team of
for version in versions {
+ let is_withheld = withheld_versions.contains_key(&version.id);
// We can see the version if:
- // - it's not hidden and we can see the project
+ // - it's not hidden and we can see the project and it's not withheld for attribution
// - we are a mod
// - we are enlisted on the team of the mod
if (!version.status.is_hidden()
+ && !is_withheld
&& visible_project_ids.contains(&version.project_id))
|| user_option.as_ref().is_some_and(|x| x.role.is_mod())
|| enlisted_version_ids.contains(&version.id)
diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs
index b44bdfe6cd..1c8c0ecefc 100644
--- a/apps/labrinth/src/background_task.rs
+++ b/apps/labrinth/src/background_task.rs
@@ -1,9 +1,11 @@
use crate::database;
use crate::database::PgPool;
use crate::database::redis::RedisPool;
+use crate::file_hosting::FileHost;
use crate::queue::analytics::cache::cache_analytics;
use crate::queue::billing::{index_billing, index_subscriptions};
use crate::queue::email::EmailQueue;
+use crate::queue::file_scan::scan_all_files;
use crate::queue::payouts::{
PayoutsQueue, index_payouts_notifications,
insert_bank_balances_and_webhook, process_affiliate_payouts,
@@ -34,6 +36,10 @@ pub enum BackgroundTask {
/// Attempts to ping Minecraft Java servers as if we were a client, to
/// collect info on if they're online, game version, description, etc.
PingMinecraftJavaServers,
+ /// Finds files of versions which have not been scanned for attributions
+ /// yet, extracts them to find file overrides, and finds any overrides which
+ /// require attribution from the creator.
+ ScanFiles,
}
impl BackgroundTask {
@@ -44,6 +50,7 @@ impl BackgroundTask {
ro_pool: PgPool,
redis_pool: RedisPool,
search_backend: web::Data,
+ file_host: web::Data,
clickhouse: clickhouse::Client,
stripe_client: stripe::Client,
anrok_client: anrok::Client,
@@ -90,6 +97,7 @@ impl BackgroundTask {
PingMinecraftJavaServers => {
ping_minecraft_java_servers(pool, redis_pool, clickhouse).await
}
+ ScanFiles => scan_all_files(&pool, &redis_pool, &**file_host).await,
}
}
}
diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs
index d88c601805..2bfc859d1e 100644
--- a/apps/labrinth/src/database/models/ids.rs
+++ b/apps/labrinth/src/database/models/ids.rs
@@ -1,12 +1,12 @@
use super::DatabaseError;
use crate::database::PgTransaction;
use crate::models::ids::{
- AffiliateCodeId, AnalyticsEventId, ChargeId, CollectionId, FileId, ImageId,
- NotificationId, OAuthAccessTokenId, OAuthClientAuthorizationId,
- OAuthClientId, OAuthRedirectUriId, OrganizationId, PatId, PayoutId,
- ProductId, ProductPriceId, ProjectId, ReportId, SessionId,
- SharedInstanceId, SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId,
- ThreadMessageId, UserSubscriptionId, VersionId,
+ AffiliateCodeId, AnalyticsEventId, AttributionGroupId, ChargeId,
+ CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId,
+ OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId,
+ OrganizationId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId,
+ ReportId, SessionId, SharedInstanceId, SharedInstanceVersionId, TeamId,
+ TeamMemberId, ThreadId, ThreadMessageId, UserSubscriptionId, VersionId,
};
use ariadne::ids::base62_impl::to_base62;
use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range};
@@ -168,6 +168,10 @@ db_id_interface!(
CollectionId,
generator: generate_collection_id @ "collections",
);
+db_id_interface!(
+ AttributionGroupId,
+ generator: generate_attribution_group_id @ "project_attribution_groups",
+);
db_id_interface!(
FileId,
generator: generate_file_id @ "files",
diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs
index 904f799e62..9fa9091a9b 100644
--- a/apps/labrinth/src/database/models/project_item.rs
+++ b/apps/labrinth/src/database/models/project_item.rs
@@ -6,6 +6,7 @@ use super::{DBUser, ids::*};
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::database::{PgTransaction, models};
+use crate::file_hosting::FileHost;
use crate::models::exp;
use crate::models::ids::ProjectId;
use crate::models::projects::{
@@ -187,6 +188,8 @@ impl ProjectBuilder {
pub async fn insert(
self,
transaction: &mut PgTransaction<'_>,
+ redis: &RedisPool,
+ file_host: &dyn FileHost,
http: &reqwest::Client,
) -> Result {
let project_struct = DBProject {
@@ -235,7 +238,7 @@ impl ProjectBuilder {
for mut version in self.initial_versions {
version.project_id = self.project_id;
- version.insert(&mut *transaction, http).await?;
+ version.insert(transaction, redis, file_host, http).await?;
}
LinkUrl::insert_many_projects(
diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs
index 6ffaf90c7f..7f8458d211 100644
--- a/apps/labrinth/src/database/models/version_item.rs
+++ b/apps/labrinth/src/database/models/version_item.rs
@@ -6,8 +6,11 @@ use crate::database::models::loader_fields::{
QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField,
};
use crate::database::redis::RedisPool;
+use crate::file_hosting::FileHost;
use crate::models::exp;
+
use crate::models::projects::{FileType, VersionStatus};
+use crate::queue::file_scan::scan_file;
use crate::routes::internal::delphi::DelphiRunParameters;
use chrono::{DateTime, Utc};
use dashmap::{DashMap, DashSet};
@@ -17,10 +20,31 @@ use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::HashMap;
use std::iter;
+use tracing::error;
pub const VERSIONS_NAMESPACE: &str = "versions";
const VERSION_FILES_NAMESPACE: &str = "versions_files";
+pub async fn cleanup_empty_attribution_groups(
+ transaction: &mut PgTransaction<'_>,
+) -> Result<(), DatabaseError> {
+ sqlx::query!(
+ "
+ DELETE FROM project_attribution_groups g
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM project_attribution_files paf
+ INNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1
+ WHERE paf.group_id = g.id
+ )
+ ",
+ )
+ .execute(&mut *transaction)
+ .await?;
+
+ Ok(())
+}
+
#[derive(Clone)]
pub struct VersionBuilder {
pub version_id: DBVersionId,
@@ -134,7 +158,10 @@ impl VersionFileBuilder {
pub async fn insert(
self,
version_id: DBVersionId,
+ project_id: DBProjectId,
transaction: &mut PgTransaction<'_>,
+ redis: &RedisPool,
+ file_host: &dyn FileHost,
http: &reqwest::Client,
) -> Result {
let file_id = generate_file_id(&mut *transaction).await?;
@@ -169,6 +196,16 @@ impl VersionFileBuilder {
.await?;
}
+ sqlx::query!(
+ "
+ INSERT INTO file_scans (file_id)
+ VALUES ($1)
+ ",
+ file_id as DBFileId,
+ )
+ .execute(&mut *transaction)
+ .await?;
+
if let Err(err) = crate::routes::internal::delphi::run(
&mut *transaction,
DelphiRunParameters {
@@ -178,7 +215,20 @@ impl VersionFileBuilder {
)
.await
{
- tracing::error!("Error submitting new file to Delphi: {err}");
+ error!("Error submitting new file to Delphi: {err:?}");
+ }
+
+ if let Err(err) = scan_file(
+ &mut *transaction,
+ redis,
+ file_host,
+ project_id,
+ file_id,
+ &self.url,
+ )
+ .await
+ {
+ error!("Error scanning new file {file_id:?}: {err:?}");
}
Ok(file_id)
@@ -195,6 +245,8 @@ impl VersionBuilder {
pub async fn insert(
self,
transaction: &mut PgTransaction<'_>,
+ redis: &RedisPool,
+ file_host: &dyn FileHost,
http: &reqwest::Client,
) -> Result {
let version = DBVersion {
@@ -236,7 +288,15 @@ impl VersionBuilder {
} = self;
for file in files {
- file.insert(version_id, transaction, http).await?;
+ file.insert(
+ version_id,
+ self.project_id,
+ transaction,
+ redis,
+ file_host,
+ http,
+ )
+ .await?;
}
DependencyBuilder::insert_many(
@@ -426,6 +486,8 @@ impl DBVersion {
.execute(&mut *transaction)
.await?;
+ cleanup_empty_attribution_groups(transaction).await?;
+
// Sync dependencies
let project_id = sqlx::query!(
@@ -862,14 +924,14 @@ impl DBVersion {
})
}
- pub async fn get_files_from_hash<'a, 'b, E>(
+ pub async fn get_files_from_hash<'a, E>(
algorithm: String,
hashes: &[String],
executor: E,
redis: &RedisPool,
) -> Result, DatabaseError>
where
- E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy,
+ E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
let val = redis.get_cached_keys(
VERSION_FILES_NAMESPACE,
diff --git a/apps/labrinth/src/file_hosting/mock.rs b/apps/labrinth/src/file_hosting/mock.rs
index 3e414bd393..8f18da5334 100644
--- a/apps/labrinth/src/file_hosting/mock.rs
+++ b/apps/labrinth/src/file_hosting/mock.rs
@@ -29,9 +29,7 @@ impl FileHost for MockHost {
file_publicity: FileHostPublicity,
file_bytes: Bytes,
) -> Result {
- let file_name = urlencoding::decode(file_name)
- .map_err(|_| FileHostingError::InvalidFilename)?;
- let path = get_file_path(&file_name, file_publicity);
+ let path = get_file_path(file_name, file_publicity);
std::fs::create_dir_all(
path.parent().ok_or(FileHostingError::InvalidFilename)?,
)?;
@@ -72,6 +70,16 @@ impl FileHost for MockHost {
file_name: file_name.to_string(),
})
}
+
+ async fn read_file(
+ &self,
+ file_name: &str,
+ file_publicity: FileHostPublicity,
+ ) -> Result {
+ let path = get_file_path(file_name, file_publicity);
+ let data = std::fs::read(&path)?;
+ Ok(Bytes::from(data))
+ }
}
fn get_file_path(
diff --git a/apps/labrinth/src/file_hosting/mod.rs b/apps/labrinth/src/file_hosting/mod.rs
index 667f4cb21e..29fd25d8cf 100644
--- a/apps/labrinth/src/file_hosting/mod.rs
+++ b/apps/labrinth/src/file_hosting/mod.rs
@@ -45,7 +45,11 @@ pub enum FileHostPublicity {
}
#[async_trait]
-pub trait FileHost {
+pub trait FileHost: Send + Sync {
+ /// Uploads a file at the exact storage key provided.
+ ///
+ /// Callers must URL-decode keys derived from public URLs before passing
+ /// them here, and URL-encode this key before exposing it in a public URL.
async fn upload_file(
&self,
content_type: &str,
@@ -54,17 +58,35 @@ pub trait FileHost {
file_bytes: Bytes,
) -> Result;
+ /// Returns a private URL for the exact storage key provided.
+ ///
+ /// Callers must URL-decode keys derived from public URLs before passing
+ /// them here.
async fn get_url_for_private_file(
&self,
file_name: &str,
expiry_secs: u32,
) -> Result;
+ /// Deletes the file at the exact storage key provided.
+ ///
+ /// Callers must URL-decode keys derived from public URLs before passing
+ /// them here.
async fn delete_file(
&self,
file_name: &str,
file_publicity: FileHostPublicity,
) -> Result;
+
+ /// Reads the file at the exact storage key provided.
+ ///
+ /// Callers must URL-decode keys derived from public URLs before passing
+ /// them here.
+ async fn read_file(
+ &self,
+ file_name: &str,
+ file_publicity: FileHostPublicity,
+ ) -> Result;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
diff --git a/apps/labrinth/src/file_hosting/s3_host.rs b/apps/labrinth/src/file_hosting/s3_host.rs
index 56bd0ef45c..558e4d5086 100644
--- a/apps/labrinth/src/file_hosting/s3_host.rs
+++ b/apps/labrinth/src/file_hosting/s3_host.rs
@@ -169,4 +169,28 @@ impl FileHost for S3Host {
file_name: file_name.to_string(),
})
}
+
+ async fn read_file(
+ &self,
+ file_name: &str,
+ file_publicity: FileHostPublicity,
+ ) -> Result {
+ let bucket = self.get_bucket(file_publicity);
+
+ let response = bucket
+ .client
+ .get_object()
+ .bucket(bucket.name.as_str())
+ .key(file_name)
+ .send()
+ .await
+ .map_err(|e| s3_error("reading file", e))?;
+
+ Ok(response
+ .body
+ .collect()
+ .await
+ .map_err(|e| s3_error("reading file body", e))?
+ .into_bytes())
+ }
}
diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs
index 15363ed09b..d1c4b30fa4 100644
--- a/apps/labrinth/src/lib.rs
+++ b/apps/labrinth/src/lib.rs
@@ -57,7 +57,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,
@@ -82,7 +82,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,
@@ -339,7 +339,7 @@ pub fn app_config(
.app_data(web::Data::new(labrinth_config.redis_pool.clone()))
.app_data(web::Data::new(labrinth_config.pool.clone()))
.app_data(web::Data::new(labrinth_config.ro_pool.clone()))
- .app_data(web::Data::new(labrinth_config.file_host.clone()))
+ .app_data(labrinth_config.file_host.clone())
.app_data(labrinth_config.search_backend.clone())
.app_data(web::Data::new(labrinth_config.gotenberg_client.clone()))
.app_data(labrinth_config.http_client.clone())
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 519c0d4765..82ef1bd2d8 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!(CollectionId);
base62_id!(FileId);
diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs
index 92abe3fddb..37df786b9c 100644
--- a/apps/labrinth/src/models/v3/projects.rs
+++ b/apps/labrinth/src/models/v3/projects.rs
@@ -12,6 +12,7 @@ use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
+use url::Url;
use validator::Validate;
/// A project returned from the API
@@ -645,6 +646,80 @@ impl SideTypesMigrationReviewStatus {
}
}
+#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
+pub struct MissingAttributionFile {
+ pub id: FileId,
+ pub override_source: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum OverrideSource {
+ Flame {
+ id: u32,
+ title: String,
+ url: String,
+ icon_url: String,
+ },
+ Unknown,
+}
+
+#[derive(
+ Debug, Serialize, Deserialize, Clone, PartialEq, Eq, utoipa::ToSchema,
+)]
+#[serde(untagged)]
+pub enum AttributionLicense {
+ Spdx(String),
+ Custom { name: String },
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum AttributionResolutionKind {
+ License {
+ license: AttributionLicense,
+ link_to_work: Url,
+ },
+ GloballyAllowed {
+ link_to_work: Url,
+ },
+ MyProject {
+ license: AttributionLicense,
+ },
+ SpecialPermissions {
+ link_to_work: Url,
+ },
+ NoPermission {
+ link_to_work: Option,
+ },
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum AttributionModerationStatusKind {
+ NotAllowed,
+ Approved,
+ BadProof,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
+pub struct AttributionModerationStatus {
+ #[serde(flatten)]
+ pub kind: AttributionModerationStatusKind,
+ #[serde(default)]
+ pub reason: String,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
+pub struct AttributionResolution {
+ #[serde(flatten)]
+ pub kind: AttributionResolutionKind,
+ #[serde(default)]
+ pub moderation_status: Option,
+ pub notes: String,
+ pub image_urls: Vec,
+}
+
/// A specific version of a project
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct Version {
@@ -681,6 +756,9 @@ pub struct Version {
/// A list of files available for download for this version.
pub files: Vec,
+ /// Files in this version that contain override files not yet attributed.
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub files_missing_attribution: Vec,
/// A list of projects that this version depends on.
pub dependencies: Vec,
@@ -757,6 +835,7 @@ impl From for Version {
dependency_type: DependencyType::from_string(
d.dependency_type.as_str(),
),
+ attribution: None,
})
.collect(),
loaders: data.loaders.into_iter().map(Loader).collect(),
@@ -768,6 +847,7 @@ impl From for Version {
.map(|vf| (vf.field_name, vf.value.serialize_internal()))
.collect(),
components: data.components,
+ files_missing_attribution: Vec::new(),
}
}
}
@@ -899,6 +979,20 @@ pub struct Dependency {
pub file_name: Option,
/// The type of the dependency
pub dependency_type: DependencyType,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub attribution: Option,
+}
+
+#[derive(
+ Serialize, Deserialize, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
+)]
+pub struct DependencyAttribution {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub link: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub icon_url: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub license: Option,
}
#[derive(
diff --git a/apps/labrinth/src/queue/file_scan.rs b/apps/labrinth/src/queue/file_scan.rs
new file mode 100644
index 0000000000..2e2611c529
--- /dev/null
+++ b/apps/labrinth/src/queue/file_scan.rs
@@ -0,0 +1,1025 @@
+use std::collections::HashMap;
+use std::io::{Cursor, Read};
+
+use chrono::Utc;
+use eyre::{Result, eyre};
+use hex::ToHex;
+use sha1::Digest;
+use tokio::task::spawn_blocking;
+use tracing::{Instrument, info, info_span, warn};
+use zip::ZipArchive;
+
+use crate::database::models::ids::{
+ DBAttributionGroupId, DBProjectId, DBVersionId,
+ generate_attribution_group_id,
+};
+use crate::database::models::moderation_external_item::ExternalLicense;
+use crate::database::models::{DBFileId, DBUserId, DBVersion};
+use crate::database::{PgPool, PgTransaction, redis::RedisPool};
+use crate::env::ENV;
+use crate::file_hosting::{FileHost, FileHostPublicity};
+use crate::models::ids::FileId;
+use crate::models::projects::{
+ AttributionResolution, AttributionResolutionKind,
+};
+use crate::queue::moderation::{
+ ApprovalType, FingerprintResponse, FlameProject, FlameResponse,
+};
+use crate::routes::internal::attribution::FlameProject as AttributionFlameProject;
+use crate::util::error::Context;
+use crate::util::http::HTTP_CLIENT;
+
+/// Attribution enforcement is version-scoped, not file-hash-scoped.
+///
+/// Versions listed in `version_attribution_exemptions` are legacy public
+/// versions that predate this attribution system. They are not scanned for
+/// attribution requirements and must not cause missing-attribution withholding.
+/// A later non-exempt version can still contain the same override SHA1 and
+/// create attribution groups/files for that SHA1. Because of that, reverse
+/// lookups from override SHA1s to versions must go through the
+/// `attribution_enforced_versions` view so grandfathered versions are ignored
+/// without making the SHA1 itself exempt.
+pub async fn scan_all_files(
+ db: &PgPool,
+ redis: &RedisPool,
+ file_host: &dyn FileHost,
+) -> Result<()> {
+ let mut txn = db.begin().await.wrap_err("beginning transaction")?;
+
+ let files_to_scan = sqlx::query!(
+ r#"
+ select
+ fa.file_id as "file_id: DBFileId",
+ f.url,
+ v.mod_id as "project_id: DBProjectId"
+ from file_scans fa
+ inner join files f on f.id = fa.file_id
+ inner join versions v on v.id = f.version_id
+ where fa.attributions_scanned_at is null
+ "#
+ )
+ .fetch_all(&mut txn)
+ .await
+ .wrap_err("fetching files to scan")?;
+
+ info!("Found {} files to scan", files_to_scan.len());
+
+ let mut scanned_ids = Vec::new();
+
+ for row in files_to_scan {
+ let human_file_id = FileId::from(row.file_id);
+ let span = info_span!("scan", file_id = %human_file_id);
+ async {
+ info!("Scanning file");
+
+ let file_id = row.file_id;
+
+ let overrides = extract_override_files_from_storage(
+ file_host, file_id, &row.url,
+ )
+ .await
+ .wrap_err_with(|| {
+ eyre!("extracting overrides for file {file_id:?}")
+ })?;
+
+ if overrides.is_empty() {
+ info!("Found no overrides");
+ } else {
+ info!("Found {} overrides", overrides.len());
+
+ let resolved = resolve_overrides(&overrides, redis, &mut txn)
+ .await
+ .wrap_err_with(|| {
+ eyre!("resolving overrides for file {file_id:?}")
+ })?;
+ info!("Resolved: {resolved:#?}");
+
+ persist_attribution_results(
+ row.project_id,
+ file_id,
+ &overrides,
+ &resolved,
+ &mut txn,
+ )
+ .await
+ .wrap_err_with(|| {
+ eyre!("persisting attribution results for file {file_id:?}")
+ })?;
+ }
+
+ scanned_ids.push(file_id.0);
+ eyre::Ok(())
+ }
+ .instrument(span)
+ .await?;
+ }
+
+ if !scanned_ids.is_empty() {
+ let now = Utc::now();
+ sqlx::query!(
+ "
+ update file_scans
+ set attributions_scanned_at = now
+ from unnest($1::bigint[], $2::timestamptz[]) as u(id, now)
+ where file_scans.file_id = u.id
+ ",
+ &scanned_ids,
+ &vec![now; scanned_ids.len()],
+ )
+ .execute(&mut txn)
+ .await
+ .wrap_err("marking files as scanned")?;
+ }
+
+ info!("Marked {} files as scanned", scanned_ids.len());
+
+ txn.commit().await.wrap_err("committing transaction")?;
+
+ Ok(())
+}
+
+pub async fn scan_file(
+ txn: &mut PgTransaction<'_>,
+ redis: &RedisPool,
+ file_host: &dyn FileHost,
+ project_id: DBProjectId,
+ file_id: DBFileId,
+ file_url: &str,
+) -> Result<()> {
+ let overrides =
+ extract_override_files_from_storage(file_host, file_id, file_url)
+ .await
+ .wrap_err_with(|| {
+ eyre!("extracting overrides for file {file_id:?}")
+ })?;
+
+ if !overrides.is_empty() {
+ let resolved = resolve_overrides(&overrides, redis, txn)
+ .await
+ .wrap_err_with(|| {
+ eyre!("resolving overrides for file {file_id:?}")
+ })?;
+
+ persist_attribution_results(
+ project_id, file_id, &overrides, &resolved, txn,
+ )
+ .await
+ .wrap_err_with(|| {
+ eyre!("persisting attribution results for file {file_id:?}")
+ })?;
+ }
+
+ sqlx::query!(
+ "
+ insert into file_scans (file_id, attributions_scanned_at)
+ values ($1, now())
+ on conflict (file_id) do update set attributions_scanned_at = now()
+ ",
+ file_id.0,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("marking file as scanned")?;
+
+ Ok(())
+}
+
+pub async fn scan_override_files(
+ file_host: &dyn FileHost,
+ file_id: DBFileId,
+ file_url: &str,
+) -> Result> {
+ extract_override_files_from_storage(file_host, file_id, file_url)
+ .await
+ .wrap_err_with(|| eyre!("extracting overrides for file {file_id:?}"))
+}
+
+async fn extract_override_files_from_storage(
+ file_host: &dyn FileHost,
+ file_id: DBFileId,
+ file_url: &str,
+) -> Result> {
+ let key = file_url
+ .strip_prefix(&ENV.CDN_URL)
+ .unwrap_or(file_url)
+ .trim_start_matches('/');
+ let key = urlencoding::decode(key).wrap_err("decoding file URL path")?;
+
+ let file_data = file_host
+ .read_file(&key, FileHostPublicity::Public)
+ .await
+ .wrap_err_with(|| {
+ eyre!("reading file {file_id:?} from storage at {key}")
+ })?;
+
+ spawn_blocking(move || extract_override_files(&file_data))
+ .await
+ .wrap_err("extracting override files")?
+ .wrap_err("extracting override files")
+}
+
+#[derive(Debug)]
+pub struct OverrideFile {
+ pub path: String,
+ pub sha1: String,
+ pub murmur2: u32,
+}
+
+#[derive(Debug)]
+pub enum OverrideResolution {
+ OnModrinth,
+ ExternalLicense {
+ id: i64,
+ status: ApprovalType,
+ link: Option,
+ flame_project: Option,
+ },
+ Flame(AttributionFlameProject),
+ Unknown,
+}
+
+const OVERRIDE_PREFIXES: &[&str] = &[
+ "overrides/mods",
+ "client-overrides/mods",
+ "server-overrides/mods",
+ "overrides/shaderpacks",
+ "client-overrides/shaderpacks",
+ "overrides/resourcepacks",
+ "client-overrides/resourcepacks",
+];
+
+fn extract_override_files(data: &[u8]) -> Result> {
+ let reader = Cursor::new(data);
+ let mut zip =
+ ZipArchive::new(reader).wrap_err("creating zip archive reader")?;
+
+ let mut files = Vec::new();
+
+ for i in 0..zip.len() {
+ let mut file = zip
+ .by_index(i)
+ .wrap_err_with(|| eyre!("reading file {i}"))?;
+ let name = file.name().to_string();
+
+ if file.is_dir() {
+ continue;
+ }
+
+ if !OVERRIDE_PREFIXES
+ .iter()
+ .any(|prefix| name.starts_with(prefix))
+ {
+ continue;
+ }
+
+ if name.matches('/').count() > 2
+ || name.ends_with(".txt")
+ || name.ends_with(".rpo")
+ {
+ continue;
+ }
+
+ let mut contents = Vec::new();
+ file.read_to_end(&mut contents)?;
+
+ let sha1 = sha1::Sha1::digest(&contents).encode_hex::();
+ let murmur = hash_flame_murmur32(contents);
+
+ files.push(OverrideFile {
+ sha1,
+ murmur2: murmur,
+ path: name,
+ });
+ }
+
+ Ok(files)
+}
+
+async fn persist_attribution_results(
+ project_id: DBProjectId,
+ file_id: DBFileId,
+ overrides: &[OverrideFile],
+ resolved: &HashMap,
+ txn: &mut PgTransaction<'_>,
+) -> Result<()> {
+ let all_sha1s: Vec> = overrides
+ .iter()
+ .map(|f| f.sha1.as_bytes().to_vec())
+ .collect();
+
+ let already_persisted: Vec> = sqlx::query_scalar!(
+ "
+ select paf.sha1 from project_attribution_files paf
+ inner join project_attribution_groups pag on pag.id = paf.group_id
+ where pag.project_id = $1 and paf.sha1 = ANY($2)
+ ",
+ project_id as DBProjectId,
+ &all_sha1s,
+ )
+ .fetch_all(&mut *txn)
+ .await
+ .wrap_err("checking existing attribution files")?;
+
+ let mut flame_groups: HashMap<
+ u32,
+ (Vec<&OverrideFile>, Option<&OverrideResolution>),
+ > = HashMap::new();
+ let mut external_license_files: Vec<(
+ &OverrideFile,
+ i64,
+ ApprovalType,
+ Option,
+ Option,
+ )> = Vec::new();
+ let mut unknown_files: Vec<&OverrideFile> = Vec::new();
+
+ for file in overrides {
+ if already_persisted
+ .iter()
+ .any(|s| s.as_slice() == file.sha1.as_bytes())
+ {
+ continue;
+ }
+
+ match resolved.get(&file.sha1) {
+ Some(OverrideResolution::OnModrinth) => continue,
+ Some(OverrideResolution::ExternalLicense {
+ id,
+ status,
+ link,
+ flame_project,
+ }) => {
+ external_license_files.push((
+ file,
+ *id,
+ *status,
+ link.clone(),
+ flame_project.clone(),
+ ));
+ }
+ Some(res @ OverrideResolution::Flame(flame_project)) => {
+ let entry = flame_groups.entry(flame_project.id).or_default();
+ entry.0.push(file);
+ if entry.1.is_none() {
+ entry.1 = Some(res);
+ }
+ }
+ Some(OverrideResolution::Unknown) | None => {
+ unknown_files.push(file);
+ }
+ }
+ }
+
+ let existing_flame_groups = sqlx::query!(
+ r#"
+ select id as "id: DBAttributionGroupId", flame_project
+ from project_attribution_groups
+ where project_id = $1 and flame_project is not null
+ "#,
+ project_id as DBProjectId,
+ )
+ .fetch_all(&mut *txn)
+ .await
+ .wrap_err("fetching existing flame attribution groups")?;
+
+ let mut existing_flame_group_ids = HashMap::new();
+ for group in existing_flame_groups {
+ if let Some(flame_project) = group.flame_project.and_then(|fp| {
+ serde_json::from_value::(fp).ok()
+ }) {
+ existing_flame_group_ids.insert(flame_project.id, group.id);
+ }
+ }
+
+ for (file, external_license_id, status, link, flame_project) in
+ external_license_files
+ {
+ if let Some(group_id) = flame_project
+ .as_ref()
+ .and_then(|fp| existing_flame_group_ids.get(&fp.id))
+ {
+ sqlx::query!(
+ "
+ insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)
+ values ($1, $2, $3, $4)
+ ",
+ *group_id as DBAttributionGroupId,
+ &file.path,
+ &file.sha1.as_bytes().to_vec() as &[u8],
+ external_license_id,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting external license attribution file into existing flame group")?;
+
+ continue;
+ }
+
+ let attribution = default_external_license_attribution(status, link);
+ let flame_project =
+ flame_project.and_then(|fp| serde_json::to_value(fp).ok());
+ let group_id = generate_attribution_group_id(&mut *txn).await?;
+ sqlx::query!(
+ "
+ insert into project_attribution_groups (id, project_id, attribution, flame_project)
+ values ($1, $2, $3, $4)
+ ",
+ group_id as DBAttributionGroupId,
+ project_id as DBProjectId,
+ attribution,
+ flame_project,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting external license attribution group")?;
+
+ sqlx::query!(
+ "
+ insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)
+ values ($1, $2, $3, $4)
+ ",
+ group_id as DBAttributionGroupId,
+ &file.path,
+ &file.sha1.as_bytes().to_vec() as &[u8],
+ external_license_id,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting external license attribution file")?;
+ }
+
+ for (flame_project_id, (files, resolution)) in &flame_groups {
+ let group_id = if let Some(group_id) =
+ existing_flame_group_ids.get(flame_project_id)
+ {
+ *group_id
+ } else {
+ let fp = resolution
+ .and_then(|r| {
+ if let OverrideResolution::Flame(flame_project) = r {
+ Some(serde_json::to_value(flame_project).ok())
+ } else {
+ None
+ }
+ })
+ .flatten();
+
+ let id = generate_attribution_group_id(&mut *txn).await?;
+ sqlx::query!(
+ "
+ insert into project_attribution_groups (id, project_id, flame_project)
+ values ($1, $2, $3)
+ ",
+ id as DBAttributionGroupId,
+ project_id as DBProjectId,
+ fp,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting attribution group")?;
+ existing_flame_group_ids.insert(*flame_project_id, id);
+ id
+ };
+
+ let names: Vec = files.iter().map(|f| f.path.clone()).collect();
+ let sha1s: Vec> =
+ files.iter().map(|f| f.sha1.as_bytes().to_vec()).collect();
+
+ sqlx::query!(
+ "
+ insert into project_attribution_files (group_id, name, sha1)
+ select $1, unnest($2::text[]), unnest($3::bytea[])
+ ",
+ group_id as DBAttributionGroupId,
+ &names,
+ &sha1s,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting attribution files")?;
+ }
+
+ for file in &unknown_files {
+ let group_id = generate_attribution_group_id(&mut *txn).await?;
+ sqlx::query!(
+ "
+ insert into project_attribution_groups (id, project_id)
+ values ($1, $2)
+ ",
+ group_id as DBAttributionGroupId,
+ project_id as DBProjectId,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting unknown attribution group")?;
+
+ sqlx::query!(
+ "
+ insert into project_attribution_files (group_id, name, sha1)
+ values ($1, $2, $3)
+ ",
+ group_id as DBAttributionGroupId,
+ &file.path,
+ &file.sha1.as_bytes().to_vec() as &[u8],
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting unknown attribution file")?;
+ }
+
+ if !all_sha1s.is_empty() {
+ sqlx::query!(
+ "
+ insert into override_file_sources (sha1, file_id)
+ select unnest($1::bytea[]), $2
+ on conflict do nothing
+ ",
+ &all_sha1s,
+ file_id as DBFileId,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting override file sources")?;
+ }
+
+ Ok(())
+}
+
+fn default_external_license_attribution(
+ status: ApprovalType,
+ link: Option,
+) -> Option {
+ match status {
+ ApprovalType::Yes
+ | ApprovalType::WithAttributionAndSource
+ | ApprovalType::WithAttribution => link
+ .and_then(|link| url::Url::parse(&link).ok())
+ .and_then(|link_to_work| {
+ serde_json::to_value(AttributionResolution {
+ kind: AttributionResolutionKind::GloballyAllowed {
+ link_to_work,
+ },
+ moderation_status: None,
+ notes: String::new(),
+ image_urls: Vec::new(),
+ })
+ .ok()
+ }),
+ ApprovalType::No => {
+ let link_to_work =
+ link.and_then(|link| url::Url::parse(&link).ok());
+
+ serde_json::to_value(AttributionResolution {
+ kind: AttributionResolutionKind::NoPermission { link_to_work },
+ moderation_status: None,
+ notes: String::new(),
+ image_urls: Vec::new(),
+ })
+ .ok()
+ }
+ ApprovalType::PermanentNo | ApprovalType::Unidentified => None,
+ }
+}
+
+async fn resolve_overrides(
+ overrides: &[OverrideFile],
+ redis: &RedisPool,
+ txn: &mut PgTransaction<'_>,
+) -> Result> {
+ let mut results: HashMap = HashMap::new();
+ let mut remaining: Vec = (0..overrides.len()).collect();
+
+ if overrides.is_empty() {
+ return Ok(results);
+ }
+
+ let hashes: Vec =
+ overrides.iter().map(|x| x.sha1.clone()).collect();
+ let files = DBVersion::get_files_from_hash(
+ "sha1".to_string(),
+ &hashes,
+ &mut *txn,
+ redis,
+ )
+ .await
+ .wrap_err("fetching files on platform by hash")?;
+
+ let version_ids: Vec<_> = files.iter().map(|x| x.version_id).collect();
+ let versions_data = DBVersion::get_many(&version_ids, &mut *txn, redis)
+ .await
+ .wrap_err("fetching versions")?;
+
+ for file in &files {
+ if !versions_data.iter().any(|v| v.inner.id == file.version_id) {
+ continue;
+ }
+
+ if let Some(hash) = file.hashes.get("sha1")
+ && let Some(pos) =
+ remaining.iter().position(|i| overrides[*i].sha1 == *hash)
+ {
+ let idx = remaining.remove(pos);
+ results.insert(
+ overrides[idx].sha1.clone(),
+ OverrideResolution::OnModrinth,
+ );
+ }
+ }
+
+ if remaining.is_empty() {
+ return Ok(results);
+ }
+
+ let rows = sqlx::query!(
+ "
+ SELECT encode(mef.sha1, 'escape') sha1, mel.id, mel.status status, mel.link
+ FROM moderation_external_files mef
+ INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id
+ WHERE mef.sha1 = ANY($1)
+ ",
+ &remaining
+ .iter()
+ .map(|i| overrides[*i].sha1.as_bytes().to_vec())
+ .collect::>()
+ )
+ .fetch_all(&mut *txn)
+ .await
+ .wrap_err("fetching external file licenses")?;
+
+ let mut direct_external_licenses = HashMap::new();
+ for row in rows {
+ if let Some(sha1) = row.sha1 {
+ direct_external_licenses.insert(
+ sha1,
+ (
+ row.id,
+ ApprovalType::from_string(&row.status)
+ .unwrap_or(ApprovalType::Unidentified),
+ row.link,
+ ),
+ );
+ }
+ }
+
+ let fingerprints: Vec =
+ remaining.iter().map(|i| overrides[*i].murmur2).collect();
+ let res = HTTP_CLIENT
+ .post(format!("{}/v1/fingerprints", ENV.FLAME_ANVIL_URL))
+ .json(&serde_json::json!({
+ "fingerprints": fingerprints
+ }))
+ .send()
+ .await;
+
+ if let Err(e) = &res {
+ warn!("Flame fingerprint request failed: {e}");
+ }
+
+ if let Ok(res) = res {
+ let body = res
+ .text()
+ .await
+ .wrap_err("reading Flame fingerprint response")?;
+
+ let flame_files: Vec<_> =
+ serde_json::from_str::>(&body)
+ .ok()
+ .map(|x| {
+ x.data
+ .exact_matches
+ .into_iter()
+ .map(|m| m.file)
+ .collect::>()
+ })
+ .unwrap_or_default();
+
+ let mut flame_matches: Vec<(String, u32)> = Vec::new();
+ for flame_file in &flame_files {
+ if let Some(hash) = flame_file
+ .hashes
+ .iter()
+ .find(|x| x.algo == 1)
+ .map(|x| x.value.clone())
+ {
+ flame_matches.push((hash, flame_file.mod_id));
+ }
+ }
+
+ let project_license_rows = sqlx::query!(
+ "
+ SELECT mel.id, mel.flame_project_id, mel.status status, mel.link
+ FROM moderation_external_licenses mel
+ WHERE mel.flame_project_id = ANY($1)
+ ",
+ &flame_matches.iter().map(|x| x.1 as i32).collect::>()
+ )
+ .fetch_all(&mut *txn)
+ .await
+ .wrap_err("fetching Flame project licenses")?;
+
+ let mut project_external_licenses = HashMap::new();
+ for row in project_license_rows {
+ if let Some(flame_project_id) = row.flame_project_id {
+ project_external_licenses.insert(
+ flame_project_id as u32,
+ (
+ row.id,
+ ApprovalType::from_string(&row.status)
+ .unwrap_or(ApprovalType::Unidentified),
+ row.link,
+ ),
+ );
+ }
+ }
+
+ let flame_projects_res = HTTP_CLIENT
+ .post(format!("{}/v1/mods", ENV.FLAME_ANVIL_URL))
+ .json(&serde_json::json!({
+ "modIds": flame_matches.iter().map(|x| x.1).collect::>()
+ }))
+ .send()
+ .await;
+
+ let flame_projects = match flame_projects_res {
+ Ok(res) => res
+ .text()
+ .await
+ .ok()
+ .and_then(|t| {
+ serde_json::from_str::>>(&t)
+ .ok()
+ })
+ .map(|x| x.data)
+ .unwrap_or_default(),
+ Err(e) => {
+ warn!("Flame projects request failed: {e}");
+ Vec::new()
+ }
+ };
+
+ let mut insert_hashes = Vec::new();
+ let mut insert_filenames = Vec::new();
+ let mut insert_ids = Vec::new();
+
+ for (sha1, flame_project_id) in &flame_matches {
+ if let Some(remaining_pos) =
+ remaining.iter().position(|i| overrides[*i].sha1 == *sha1)
+ {
+ let idx = remaining.remove(remaining_pos);
+ let project =
+ flame_projects.iter().find(|p| p.id == *flame_project_id);
+ let flame_project = AttributionFlameProject {
+ id: *flame_project_id,
+ title: project.map(|p| p.name.clone()).unwrap_or_else(
+ || format!("Flame project {flame_project_id}"),
+ ),
+ url: project
+ .map(|p| p.links.website_url.clone())
+ .unwrap_or_default(),
+ icon_url: project
+ .map(|p| p.logo.thumbnail_url.clone())
+ .unwrap_or_default(),
+ };
+
+ if let Some((id, status, link)) =
+ direct_external_licenses.remove(&overrides[idx].sha1)
+ {
+ results.insert(
+ overrides[idx].sha1.clone(),
+ OverrideResolution::ExternalLicense {
+ id,
+ status,
+ link,
+ flame_project: Some(flame_project),
+ },
+ );
+ } else if let Some((id, status, link)) =
+ project_external_licenses.get(flame_project_id)
+ {
+ results.insert(
+ overrides[idx].sha1.clone(),
+ OverrideResolution::ExternalLicense {
+ id: *id,
+ status: *status,
+ link: link.clone(),
+ flame_project: Some(flame_project),
+ },
+ );
+
+ insert_hashes.push(overrides[idx].sha1.as_bytes().to_vec());
+ insert_filenames.push(Some(overrides[idx].path.clone()));
+ insert_ids.push(*id);
+ } else {
+ results.insert(
+ overrides[idx].sha1.clone(),
+ OverrideResolution::Flame(flame_project),
+ );
+ }
+ }
+ }
+
+ if !insert_hashes.is_empty() {
+ ExternalLicense::insert_files(
+ &mut *txn,
+ &insert_hashes,
+ &insert_filenames,
+ &insert_ids,
+ DBUserId(0),
+ )
+ .await
+ .wrap_err("inserting external license files")?;
+ }
+ }
+
+ remaining.retain(|idx| {
+ if let Some((id, status, link)) =
+ direct_external_licenses.remove(&overrides[*idx].sha1)
+ {
+ results.insert(
+ overrides[*idx].sha1.clone(),
+ OverrideResolution::ExternalLicense {
+ id,
+ status,
+ link,
+ flame_project: None,
+ },
+ );
+ false
+ } else {
+ true
+ }
+ });
+
+ for idx in remaining {
+ results
+ .insert(overrides[idx].sha1.clone(), OverrideResolution::Unknown);
+ }
+
+ Ok(results)
+}
+
+fn hash_flame_murmur32(input: Vec) -> u32 {
+ murmur2::murmur2(
+ &input
+ .into_iter()
+ .filter(|x| *x != 9 && *x != 10 && *x != 13 && *x != 32)
+ .collect::>(),
+ 1,
+ )
+}
+
+pub async fn get_files_missing_attribution<'a, E>(
+ exec: E,
+ version_ids: &[DBVersionId],
+) -> Result<
+ std::collections::HashMap<
+ DBVersionId,
+ Vec<(DBFileId, Option)>,
+ >,
+>
+where
+ E: sqlx::Executor<'a, Database = sqlx::Postgres>,
+{
+ if version_ids.is_empty() {
+ return Ok(std::collections::HashMap::new());
+ }
+
+ let rows = sqlx::query!(
+ r#"
+ select distinct f.version_id as "version_id: DBVersionId", f.id as "file_id: DBFileId",
+ pag.flame_project
+ from files f
+ inner join attribution_enforced_versions aev on aev.id = f.version_id
+ inner join override_file_sources ofs on ofs.file_id = f.id
+ inner join project_attribution_files paf on paf.sha1 = ofs.sha1
+ inner join project_attribution_groups pag on pag.id = paf.group_id
+ where f.version_id = ANY($1)
+ and (
+ pag.attribution is null
+ or pag.attribution->>'kind' = 'no_permission'
+ or (
+ pag.attribution->'moderation_status' is not null
+ and pag.attribution->'moderation_status'->>'kind' != 'approved'
+ )
+ )
+ "#,
+ &version_ids.iter().map(|v| v.0).collect::>(),
+ )
+ .fetch_all(exec)
+ .await
+ .wrap_err("fetching files missing attribution")?;
+
+ let mut result = std::collections::HashMap::new();
+ for row in rows {
+ let flame_project = row
+ .flame_project
+ .and_then(|v| serde_json::from_value(v).ok());
+ result
+ .entry(row.version_id)
+ .or_insert_with(Vec::new)
+ .push((row.file_id, flame_project));
+ }
+
+ Ok(result)
+}
+
+pub struct DependencyAttributionData {
+ pub link: Option,
+ pub icon_url: Option,
+ pub license: Option,
+}
+
+pub async fn get_dependency_attributions<'a, E>(
+ exec: E,
+ version_ids: &[DBVersionId],
+) -> Result>
+where
+ E: sqlx::Executor<'a, Database = sqlx::Postgres>,
+{
+ if version_ids.is_empty() {
+ return Ok(HashMap::new());
+ }
+
+ let version_ids_vec: Vec<_> = version_ids.iter().map(|v| v.0).collect();
+
+ let rows = sqlx::query!(
+ r#"
+ select
+ d.dependent_id as "version_id: DBVersionId",
+ d.dependency_file_name as "file_name!",
+ pag.attribution,
+ pag.flame_project,
+ pag.project_id as "project_id: DBProjectId"
+ from dependencies d
+ inner join files f on f.version_id = d.dependent_id
+ inner join attribution_enforced_versions aev on aev.id = f.version_id
+ inner join override_file_sources ofs on ofs.file_id = f.id
+ inner join project_attribution_files paf on paf.sha1 = ofs.sha1
+ inner join project_attribution_groups pag on pag.id = paf.group_id
+ where d.dependent_id = ANY($1)
+ and d.dependency_file_name is not null
+ and pag.attribution is not null
+ and pag.attribution->>'kind' not in ('no_permission')
+ and (
+ pag.attribution->'moderation_status' is null
+ or pag.attribution->'moderation_status'->>'kind' = 'approved'
+ )
+ and split_part(paf.name, '/', -1) = d.dependency_file_name
+ "#,
+ &version_ids_vec,
+ )
+ .fetch_all(exec)
+ .await
+ .wrap_err("fetching dependency attributions")?;
+
+ let mut result = HashMap::new();
+ for row in rows {
+ let file_name = row.file_name;
+
+ let attribution: Option =
+ row.attribution.and_then(|v| serde_json::from_value(v).ok());
+
+ let flame_project: Option = row
+ .flame_project
+ .and_then(|v| serde_json::from_value(v).ok());
+
+ let link = attribution
+ .as_ref()
+ .and_then(|a| match &a.kind {
+ AttributionResolutionKind::License { link_to_work, .. } => {
+ Some(link_to_work.to_string())
+ }
+ AttributionResolutionKind::GloballyAllowed { link_to_work } => {
+ Some(link_to_work.to_string())
+ }
+ AttributionResolutionKind::SpecialPermissions {
+ link_to_work,
+ } => Some(link_to_work.to_string()),
+ _ => None,
+ })
+ .or(flame_project.as_ref().map(|fp| fp.url.clone()));
+
+ let icon_url = flame_project.as_ref().map(|fp| fp.icon_url.clone());
+
+ let license = attribution
+ .as_ref()
+ .and_then(|a| match &a.kind {
+ AttributionResolutionKind::License { license, .. } => {
+ Some(serde_json::to_value(license).ok())
+ }
+ _ => None,
+ })
+ .flatten();
+
+ result.insert(
+ (row.version_id, file_name),
+ DependencyAttributionData {
+ link,
+ icon_url,
+ license,
+ },
+ );
+ }
+
+ Ok(result)
+}
diff --git a/apps/labrinth/src/queue/mod.rs b/apps/labrinth/src/queue/mod.rs
index 666670dc0b..0d7dcb273b 100644
--- a/apps/labrinth/src/queue/mod.rs
+++ b/apps/labrinth/src/queue/mod.rs
@@ -1,6 +1,7 @@
pub mod analytics;
pub mod billing;
pub mod email;
+pub mod file_scan;
pub mod moderation;
pub mod payouts;
pub mod server_ping;
diff --git a/apps/labrinth/src/queue/moderation.rs b/apps/labrinth/src/queue/moderation.rs
index 7d852f4eb3..1914f80359 100644
--- a/apps/labrinth/src/queue/moderation.rs
+++ b/apps/labrinth/src/queue/moderation.rs
@@ -570,7 +570,7 @@ impl AutomatedModerationQueue {
Vec::new()
} else {
let res = client
- .post(format!("{}v1/mods", ENV.FLAME_ANVIL_URL))
+ .post(format!("{}/v1/mods", ENV.FLAME_ANVIL_URL))
.json(&serde_json::json!({
"modIds": flame_files.iter().map(|x| x.1).collect::>()
}))
@@ -823,7 +823,7 @@ pub enum ApprovalType {
}
impl ApprovalType {
- fn approved(&self) -> bool {
+ pub fn approved(&self) -> bool {
match self {
ApprovalType::Yes => true,
ApprovalType::WithAttributionAndSource => true,
@@ -901,6 +901,13 @@ pub struct FlameProject {
pub name: String,
pub slug: String,
pub links: FlameLinks,
+ pub logo: FlameLogo,
+}
+
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FlameLogo {
+ pub thumbnail_url: String,
}
#[derive(Deserialize, Serialize)]
diff --git a/apps/labrinth/src/routes/internal/attribution.rs b/apps/labrinth/src/routes/internal/attribution.rs
new file mode 100644
index 0000000000..c433142ce8
--- /dev/null
+++ b/apps/labrinth/src/routes/internal/attribution.rs
@@ -0,0 +1,614 @@
+use actix_web::{HttpRequest, get, patch, post, web};
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use sqlx::Row;
+
+use crate::auth::get_user_from_headers;
+use crate::database::PgPool;
+use crate::database::models::ids::{
+ DBAttributionGroupId, DBProjectId, generate_attribution_group_id,
+};
+use crate::database::redis::RedisPool;
+use crate::models::ids::{ProjectId, VersionId};
+use crate::models::pats::Scopes;
+use crate::models::projects::{
+ AttributionModerationStatusKind, AttributionResolution,
+ AttributionResolutionKind,
+};
+use crate::models::users::User;
+use crate::queue::moderation::ApprovalType;
+use crate::queue::session::AuthQueue;
+use crate::routes::ApiError;
+
+pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
+ cfg.service(list)
+ .service(update_group)
+ .service(assign)
+ .service(split);
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct FlameProject {
+ pub id: u32,
+ pub title: String,
+ pub url: String,
+ pub icon_url: String,
+}
+
+#[derive(Serialize)]
+struct AttributionGroupResponse {
+ id: crate::models::ids::AttributionGroupId,
+ flame_project: Option,
+ attribution: Option,
+ attributed_at: Option>,
+ attributed_by: Option,
+ files: Vec,
+ versions: Vec,
+}
+
+#[derive(Clone, Serialize)]
+struct VersionInfo {
+ id: VersionId,
+ name: String,
+ version_number: String,
+ date_created: chrono::DateTime,
+}
+
+#[derive(Serialize)]
+struct AttributionFileResponse {
+ name: String,
+ sha1: String,
+ versions: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ moderation_external_license_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ moderation_external_license: Option,
+}
+
+#[derive(Clone, Serialize)]
+struct ModerationExternalLicenseResponse {
+ id: i64,
+ title: Option,
+ status: ApprovalType,
+ link: Option,
+ exceptions: Option,
+ proof: Option,
+ flame_project_id: Option,
+ inserted_at: Option>,
+ inserted_by: Option,
+ updated_at: Option>,
+ updated_by: Option,
+}
+
+#[utoipa::path]
+#[get("/{project_id}")]
+async fn list(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ path: web::Path,
+) -> Result>, ApiError> {
+ let project_id: DBProjectId = path.into_inner().into();
+ let show_moderation_external_license_ids = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_READ,
+ )
+ .await
+ .ok()
+ .is_some_and(|(_, user)| user.role.is_mod());
+
+ let groups = sqlx::query!(
+ r#"
+ select
+ g.id as "id: DBAttributionGroupId",
+ g.flame_project,
+ g.attribution,
+ g.attributed_at,
+ g.attributed_by as "attributed_by: i64"
+ from project_attribution_groups g
+ where g.project_id = $1
+ "#,
+ project_id as DBProjectId,
+ )
+ .fetch_all(pool.as_ref())
+ .await?;
+
+ let group_ids: Vec = groups.iter().map(|g| g.id.0).collect();
+
+ let files = if group_ids.is_empty() {
+ Vec::new()
+ } else {
+ sqlx::query(
+ "
+ select paf.group_id, paf.name, convert_from(paf.sha1, 'UTF8') as sha1, paf.moderation_external_license_id,
+ coalesce(array_agg(distinct aev.id) filter (where aev.id is not null), '{}') as version_ids
+ from project_attribution_files paf
+ left join override_file_sources ofs on ofs.sha1 = paf.sha1
+ left join files f on f.id = ofs.file_id
+ left join attribution_enforced_versions aev on aev.id = f.version_id
+ where paf.group_id = ANY($1)
+ group by paf.group_id, paf.name, paf.sha1, paf.moderation_external_license_id
+ ",
+ )
+ .bind(&group_ids)
+ .fetch_all(pool.as_ref())
+ .await?
+ };
+
+ let moderation_external_licenses = if show_moderation_external_license_ids {
+ let mut ids: Vec = files
+ .iter()
+ .filter_map(|f| f.get("moderation_external_license_id"))
+ .collect();
+ ids.sort_unstable();
+ ids.dedup();
+
+ if ids.is_empty() {
+ std::collections::HashMap::new()
+ } else {
+ sqlx::query!(
+ r#"
+ select
+ id,
+ title,
+ status,
+ link,
+ exceptions,
+ proof,
+ flame_project_id,
+ inserted_at,
+ inserted_by,
+ updated_at,
+ updated_by
+ from moderation_external_licenses
+ where id = ANY($1)
+ "#,
+ &ids,
+ )
+ .fetch_all(pool.as_ref())
+ .await?
+ .into_iter()
+ .map(|row| {
+ (
+ row.id,
+ ModerationExternalLicenseResponse {
+ id: row.id,
+ title: row.title,
+ status: ApprovalType::from_string(&row.status)
+ .unwrap_or(ApprovalType::Unidentified),
+ link: row.link,
+ exceptions: row.exceptions,
+ proof: row.proof,
+ flame_project_id: row.flame_project_id,
+ inserted_at: row.inserted_at,
+ inserted_by: row.inserted_by,
+ updated_at: row.updated_at,
+ updated_by: row.updated_by,
+ },
+ )
+ })
+ .collect()
+ }
+ } else {
+ std::collections::HashMap::new()
+ };
+
+ let mut all_version_ids: Vec = files
+ .iter()
+ .flat_map(|f| f.get::, _>("version_ids"))
+ .collect();
+ all_version_ids.sort_unstable();
+ all_version_ids.dedup();
+
+ let version_infos = if all_version_ids.is_empty() {
+ Vec::new()
+ } else {
+ let rows = sqlx::query!(
+ "
+ select id, name, version_number, date_published
+ from versions
+ where id = ANY($1)
+ order by date_published desc
+ ",
+ &all_version_ids,
+ )
+ .fetch_all(pool.as_ref())
+ .await?;
+ rows.into_iter()
+ .map(|v| VersionInfo {
+ id: VersionId(v.id as u64),
+ name: v.name,
+ version_number: v.version_number,
+ date_created: v.date_published,
+ })
+ .collect()
+ };
+ let version_order = version_infos
+ .iter()
+ .enumerate()
+ .map(|(index, version)| (version.id, index))
+ .collect::>();
+
+ let mut result = Vec::new();
+ for group in groups {
+ let group_files: Vec = files
+ .iter()
+ .filter(|f| f.get::("group_id") == group.id.0)
+ .map(|f| AttributionFileResponse {
+ name: f.get("name"),
+ sha1: f.get("sha1"),
+ moderation_external_license_id:
+ if show_moderation_external_license_ids {
+ f.get("moderation_external_license_id")
+ } else {
+ None
+ },
+ moderation_external_license:
+ if show_moderation_external_license_ids {
+ f.get::