diff --git a/Cargo.toml b/Cargo.toml
index 29e6d78cc3..4a891c34ba 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -270,6 +270,7 @@ redundant_type_annotations = "warn"
result_large_err = "allow"
todo = "warn"
too_many_arguments = "allow"
+type_complexity = "allow"
uninlined_format_args = "warn"
unnested_or_patterns = "warn"
wildcard_dependencies = "warn"
diff --git a/apps/frontend/src/assets/styles/components.scss b/apps/frontend/src/assets/styles/components.scss
index 6b91a25ffe..4458f7c307 100644
--- a/apps/frontend/src/assets/styles/components.scss
+++ b/apps/frontend/src/assets/styles/components.scss
@@ -756,16 +756,16 @@ svg.inline-svg {
padding: var(--gap-16);
h2 {
+ @apply font-semibold;
font-size: var(--text-18);
- font-weight: var(--weight-extrabold);
color: var(--color-contrast);
line-height: initial;
margin: 0;
}
h3 {
+ @apply font-semibold;
font-size: var(--text-16);
- font-weight: var(--weight-bold);
color: var(--color-base);
margin: 0;
}
@@ -812,6 +812,7 @@ svg.inline-svg {
}
.details-list__item {
+ @apply font-normal;
@extend .iconified-list-item;
.details-list__item__text--style-secondary {
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/components/ui/thread/ThreadView.vue b/apps/frontend/src/components/ui/thread/ThreadView.vue
index a753014aac..0e487bc68d 100644
--- a/apps/frontend/src/components/ui/thread/ThreadView.vue
+++ b/apps/frontend/src/components/ui/thread/ThreadView.vue
@@ -59,7 +59,7 @@
- Quick Reply
+ Quick reply
diff --git a/apps/frontend/src/composables/featureFlags.ts b/apps/frontend/src/composables/featureFlags.ts
index ba6538ee81..ed8e142016 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,
@@ -56,6 +55,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showModeratorPrivateMessageHighlight: true,
archonApiStaging: false,
showHostingAccessInstanceAuditLog: false,
+ versionDevInfoCollapsed: true,
+ alwaysShowVersionDevInfo: false,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
diff --git a/apps/frontend/src/composables/queries/version.ts b/apps/frontend/src/composables/queries/version.ts
index 552410fa13..cd612b2c00 100644
--- a/apps/frontend/src/composables/queries/version.ts
+++ b/apps/frontend/src/composables/queries/version.ts
@@ -8,4 +8,11 @@ export const versionQueryOptions = {
queryFn: () => client.labrinth.versions_v3.getVersion(versionId),
staleTime: STALE_TIME,
}),
+
+ fromProject: (projectId: string, versionIdOrNumber: string, client: AbstractModrinthClient) => ({
+ queryKey: ['project', projectId, 'version', 'v3', versionIdOrNumber] as const,
+ queryFn: () =>
+ client.labrinth.versions_v3.getVersionFromIdOrNumber(projectId, versionIdOrNumber),
+ staleTime: STALE_TIME,
+ }),
}
diff --git a/apps/frontend/src/locales/de-CH/index.json b/apps/frontend/src/locales/de-CH/index.json
index 1486e8ae01..8a7ce5d785 100644
--- a/apps/frontend/src/locales/de-CH/index.json
+++ b/apps/frontend/src/locales/de-CH/index.json
@@ -3290,9 +3290,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Wenn du Hilfe benötigst oder weitere Fragen hast, besuche bitte das
Hilfezentrum von Modrinth und klicke auf die blaue Sprechblase, um den Support zu kontaktieren."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "Benutzer-UI anzeigen"
- },
"project.moderation.thread.private-description": {
"message": "Dies ist ein privater Unterhaltungsthread mit den Modrinth-Moderatoren. Sie können dich bei Problemen bezüglich dieses Projekts kontaktieren."
},
@@ -3422,9 +3419,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {Diese Version ist} other {Diese Versionen sind}} derzeit zurückgehalten und nicht öffentlich gelistet. Bitte stelle einen Nachweis bereit, dass du die Erlaubnis hast, bestimmte Dateien weiterzuverbreiten, die in {count, plural, one {der Modpack-Version} other {den Modpack-Versionen}} enthalten sind."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Beheben"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, one {Version {version_name}} other {Versionen}} zurückgehalten aufgrund von unbekannten eingebetteten Inhalten"
},
diff --git a/apps/frontend/src/locales/de-DE/index.json b/apps/frontend/src/locales/de-DE/index.json
index 74b1f93c18..91e04fed83 100644
--- a/apps/frontend/src/locales/de-DE/index.json
+++ b/apps/frontend/src/locales/de-DE/index.json
@@ -3290,9 +3290,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Falls du Hilfe benötigst oder weitere Fragen hast, besuche bitte das
Hilfezentrum von Modrinth und klicke auf die blaue Sprechblase, um den Support zu kontaktieren."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "Mitglieder-UI anzeigen"
- },
"project.moderation.thread.private-description": {
"message": "Dies ist eine private Konversation mit den Moderatoren von Modrinth. Diese können dir Nachrichten zu Problemen bezüglich dieses Projekts senden."
},
@@ -3422,9 +3419,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {Diese Version ist} other {Diese Versionen sind}} derzeit zurückgehalten und nicht öffentlich gelistet. Bitte stelle einen Nachweis bereit, dass du die Erlaubnis hast, bestimmte Dateien weiterzuverbreiten, die im Modpack in {count, plural, one {Version} other {Versionen}} enthalten sind."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Beheben"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, one {Version {version_name} wurde} other {Versionen wurden}} aufgrund unbekannter eingebetteter Inhalte zurückgehalten"
},
diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json
index 3168470cb4..57e60de127 100644
--- a/apps/frontend/src/locales/en-US/index.json
+++ b/apps/frontend/src/locales/en-US/index.json
@@ -3332,9 +3332,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "If you need assistance or have additional inquiries, please visit the
Modrinth Help Center and click the blue bubble to contact support."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "Show member UI"
- },
"project.moderation.thread.private-description": {
"message": "This is a private conversation thread with the Modrinth moderators. They may message you with issues concerning this project."
},
@@ -3405,43 +3402,76 @@
"message": "URL"
},
"project.settings.permissions.attention-needed.description.proj-approved": {
- "message": "Please provide proof that you have permission to redistribute all of the following files and any withheld versions will be automatically published."
+ "message": "Please provide proof that you have permission to redistribute all of the following files. Once completed, withheld versions will be automatically published."
},
"project.settings.permissions.attention-needed.description.proj-draft": {
- "message": "Please provide proof that you have permission to redistribute all of the following files before you can submit your project for review."
+ "message": "Please provide proof that you have permission to redistribute all of the following files before submitting your project for review."
},
"project.settings.permissions.attention-needed.title": {
- "message": "Unknown embedded content"
+ "message": "Unknown external content"
+ },
+ "project.settings.permissions.bad-proof.description": {
+ "message": "Modrinth's moderation team has rejected the permission information you provided for some external content. Please review the rejected items below and provide acceptable proof or remove the content."
+ },
+ "project.settings.permissions.bad-proof.title": {
+ "message": "Some proofs were rejected"
+ },
+ "project.settings.permissions.collapse-all": {
+ "message": "Collapse all"
},
"project.settings.permissions.completed.description": {
- "message": "All external content has attributions provided."
+ "message": "All external content has permission information and attributions have been provided."
},
"project.settings.permissions.completed.title": {
- "message": "Attributions completed!"
+ "message": "Permissions completed!"
},
"project.settings.permissions.empty-state.description": {
- "message": "None of your versions contain external content, so you don't need to worry about obtaining permissions."
+ "message": "None of your project's versions contain external content, so you don't need to worry about obtaining permissions."
},
"project.settings.permissions.empty-state.heading": {
"message": "You're all set!"
},
+ "project.settings.permissions.expand-all": {
+ "message": "Expand all"
+ },
"project.settings.permissions.fail.description": {
- "message": "You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content."
+ "message": "You may not have permission to redistribute some of the external content in your project. In order to publish on Modrinth, please remove this content or provide proof that you do have permission to use it."
},
"project.settings.permissions.fail.title": {
"message": "Some content can't be included"
},
"project.settings.permissions.info-banner.description": {
- "message": "If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out
our guide to learn about how to do this properly!"
+ "message": "If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out
our announcement of this new system to learn more!"
},
"project.settings.permissions.info-banner.title": {
- "message": "Learn how attributions work"
+ "message": "Learn about distribution permissions"
},
"project.settings.permissions.learn-more": {
"message": "Learn more"
},
+ "project.settings.permissions.no-results": {
+ "message": "No external files match your search."
+ },
+ "project.settings.permissions.not-allowed.description": {
+ "message": "Some of the external content included cannot be distributed on Modrinth because it violates our Content Rules and must be removed."
+ },
+ "project.settings.permissions.pending-approval-count": {
+ "message": "{count, plural, =0 {No attributions need approval} one {# attribution needs approval} other {# attributions need approval}}"
+ },
"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.rejected": {
+ "message": "Rejected"
+ },
+ "project.settings.permissions.sort.status": {
+ "message": "Status"
},
"project.settings.title": {
"message": "Settings"
@@ -3464,9 +3494,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {This version is} other {These versions are}} currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included in the modpack {count, plural, one {version} other {versions}}."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Resolve"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, one {Version {version_name}} other {Versions}} withheld due to unknown embedded content"
},
@@ -4616,6 +4643,48 @@
"ui.newsletter-button.tooltip": {
"message": "Subscribe to the Modrinth newsletter"
},
+ "version.all-versions": {
+ "message": "All versions"
+ },
+ "version.confirm-delete.description": {
+ "message": "This version will be permanently deleted. This action cannot be undone."
+ },
+ "version.confirm-delete.proceed": {
+ "message": "Delete version"
+ },
+ "version.confirm-delete.title": {
+ "message": "Are you sure you want to delete this version?"
+ },
+ "version.dependency.view-project": {
+ "message": "View project"
+ },
+ "version.dependency.view-version": {
+ "message": "View version"
+ },
+ "version.download.download-dependency": {
+ "message": "Download dependency"
+ },
+ "version.download.no-primary-file": {
+ "message": "Error: No primary file found"
+ },
+ "version.download.optional-resource-pack": {
+ "message": "Optional resource pack"
+ },
+ "version.download.required-resource-pack": {
+ "message": "Required resource pack"
+ },
+ "version.edit.button": {
+ "message": "Edit"
+ },
+ "version.edit.details": {
+ "message": "Edit details"
+ },
+ "version.edit.files": {
+ "message": "Edit files"
+ },
+ "version.edit.metadata": {
+ "message": "Edit metadata"
+ },
"version.environment.none.description": {
"message": "The environment for this version has not been specified."
},
@@ -4627,5 +4696,56 @@
},
"version.environment.unknown.title": {
"message": "Unknown environment"
+ },
+ "version.package-as-mod.button": {
+ "message": "Package as mod"
+ },
+ "version.package-as-mod.description": {
+ "message": "This will create a new version with support for the selected mod loaders. You will be redirected to the new version and can edit it to your liking."
+ },
+ "version.package-as-mod.header": {
+ "message": "Packaging data pack as a mod"
+ },
+ "version.package-as-mod.mod-loaders": {
+ "message": "Mod loaders"
+ },
+ "version.package-as-mod.mod-loaders.description": {
+ "message": "The mod loaders you would like to package your data pack for."
+ },
+ "version.package-as-mod.mod-loaders.placeholder": {
+ "message": "Choose mod loaders..."
+ },
+ "version.package-as-mod.submit-button": {
+ "message": "Package data pack"
+ },
+ "version.section.content.dev-info": {
+ "message": "Developer information"
+ },
+ "version.section.content.dev-info.gradle-snippet": {
+ "message": "build.gradle:"
+ },
+ "version.section.content.dev-info.maven-coordinates": {
+ "message": "Maven coordinates:"
+ },
+ "version.section.content.dev-info.maven-description": {
+ "message": "Projects on Modrinth are automatically available through a Maven repository for use with JVM build tools such as
Gradle . To learn more about the Modrinth Maven API,
click here ."
+ },
+ "version.section.content.dev-info.maven-note": {
+ "message": "Note: When available, you should use the creator's maven repo instead as it will have transitive dependency information that the Modrinth Maven API does not. You may also end up with duplicate dependencies if you use a mix of Modrinth and non-Modrinth Maven repositories for your dependencies, because the group identifier will be different when served through the Modrinth Maven API."
+ },
+ "version.section.content.dev-info.version-id": {
+ "message": "Version ID:"
+ },
+ "version.supplementary-resources.copy-hash-sha1": {
+ "message": "Copy SHA-1"
+ },
+ "version.supplementary-resources.copy-hash-sha512": {
+ "message": "Copy SHA-512"
+ },
+ "version.unknown-embedded-content.description": {
+ "message": "This version is currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included."
+ },
+ "version.unknown-embedded-content.title": {
+ "message": "Withheld due to unknown embedded content"
}
}
diff --git a/apps/frontend/src/locales/es-419/index.json b/apps/frontend/src/locales/es-419/index.json
index 5f675ef077..fddceb5a0e 100644
--- a/apps/frontend/src/locales/es-419/index.json
+++ b/apps/frontend/src/locales/es-419/index.json
@@ -3332,9 +3332,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Si necesitas ayuda o tienes consultas adicionales, por favor visita el
Centro de ayuda de Modrinth y haz click en la burbuja azúl para contactar con soporte."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "Mostrar interfaz de miembros"
- },
"project.moderation.thread.private-description": {
"message": "Este es un hilo de conversación con los moderadores de Modrinth. Es posible que te envíen mensajes sobre cuestiones relacionadas con este proyecto."
},
@@ -3464,9 +3461,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {Esta versión está retenida y no listada} other {Estas versiones están retenidas y no listadas}} públicamente. Por favor, proporciona pruebas de que tienes permiso para redistribuir algunos de los archivos incluidos en {count, plural, one {la versión} other {las versiones}} del modpack."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Resolver"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, one {Versión {version_name} retenida} other {Versiones retenidas}} debido a que incluye contenido desconocido"
},
diff --git a/apps/frontend/src/locales/es-ES/index.json b/apps/frontend/src/locales/es-ES/index.json
index be9adb4cc6..66f60dec9d 100644
--- a/apps/frontend/src/locales/es-ES/index.json
+++ b/apps/frontend/src/locales/es-ES/index.json
@@ -3326,9 +3326,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Si necesitas ayuda o tienes consultas adicionales, por favor visita el
Centro de ayuda de Modrinth y haz click en la burbuja azul para contactar con soporte."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "Mostrar interfaz de miembros"
- },
"project.moderation.thread.private-description": {
"message": "Este es un hilo de conversación privado con los moderadores de Modrinth. Es posible que te envíen mensajes sobre cuestiones relacionadas con este proyecto."
},
@@ -3458,9 +3455,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {Esta versión está retenida y no listada} other {Estas versiones están retenidas y no listadas}} públicamente. Por favor, proporciona pruebas de que tienes permiso para redistribuir algunos de los archivos incluidos en {count, plural, one {la versión} other {las versiones}} del modpack."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Resolver"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, one {Versión {version_name} retenida} other {Versiones retenidas}} debido a que incluye contenido desconocido"
},
diff --git a/apps/frontend/src/locales/fr-FR/index.json b/apps/frontend/src/locales/fr-FR/index.json
index cc87f61a07..86ea415b97 100644
--- a/apps/frontend/src/locales/fr-FR/index.json
+++ b/apps/frontend/src/locales/fr-FR/index.json
@@ -3332,9 +3332,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Si vous avez besoin d'aide ou si vous avez des demandes de renseignements supplémentaires, veuillez visiter le
Modrinth Help Center et cliquez sur la bulle bleue pour contacter le support."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "Montrer l'interface des membres"
- },
"project.moderation.thread.private-description": {
"message": "Il s'agit d'un fil de conversation privé avec les modérateurs du Modrinthe. Ils peuvent vous envoyer un message avec des problèmes concernant ce projet."
},
@@ -3464,9 +3461,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {Cette version est actuellement indisponible et n'est pas répertoriée} other {Ces versions sont actuellement indisponibles et n'ont pas été répertoriées}} publiquement. Veuillez fournir la preuve que vous avez l'autorisation de redistribuer certains fichiers inclus dans {count, plural, one {la version} other {les versions}} du modpack."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Résoudre"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, one {Version {version_name} non disponible} other {Versions non disponibles}} en raison de contenus intégrés inconnus"
},
diff --git a/apps/frontend/src/locales/hu-HU/index.json b/apps/frontend/src/locales/hu-HU/index.json
index 09a3e02a9f..496ef0bb63 100644
--- a/apps/frontend/src/locales/hu-HU/index.json
+++ b/apps/frontend/src/locales/hu-HU/index.json
@@ -3191,9 +3191,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {Ez a verzió} other {Ezek a verziók}} jelenleg vissza van tartva, és nem érhető{count, plural, one {} other {k}} el nyilvánosan. Kérjük, igazold, hogy engedéllyel rendelkezel a modcsomag {count, plural, one {verziójában} other {verzióiban}} található bizonyos fájlok terjesztésére."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Megoldás"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, one {A(z) {version_name} verzió} other {A verziók}} ismeretlen beágyazott tartalom miatt vissza lett{count, plural, one {} other {ek}} tartva"
},
diff --git a/apps/frontend/src/locales/it-IT/index.json b/apps/frontend/src/locales/it-IT/index.json
index 59eb20c037..f3249d2616 100644
--- a/apps/frontend/src/locales/it-IT/index.json
+++ b/apps/frontend/src/locales/it-IT/index.json
@@ -3323,9 +3323,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Se hai bisogno di assistenza o hai altre richieste, visita il
centro assistenza di Modrinth e clicca sulla bolla blu nell'angolo in basso a destra."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "Mostra interfaccia degli utenti"
- },
"project.moderation.thread.private-description": {
"message": "Questa è una conversazione privata con i moderatori di Modrinth. Sarai contattato per eventuali problemi riguardanti questo progetto."
},
@@ -3455,9 +3452,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {Questa versione è attualmente sospesa e non elencata} other {Queste versioni sono attualmente sospese e non elencate}} pubblicamente. Devi dimostrare di avere il permesso di ridistribuire alcuni dei file presenti in {count, plural, one {questa versione} other {queste versioni}} del pacchetto."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Risolvi"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, one {La versione {version_name} è stata sospesa} other {Alcune versioni sono state sospese}} per attribuzioni incomplete"
},
diff --git a/apps/frontend/src/locales/ko-KR/index.json b/apps/frontend/src/locales/ko-KR/index.json
index 59aa9e455d..f84dcc029c 100644
--- a/apps/frontend/src/locales/ko-KR/index.json
+++ b/apps/frontend/src/locales/ko-KR/index.json
@@ -2768,9 +2768,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "도움이 필요하거나 추가 문의 사항이 있다면
Modrinth 도움말 센터 를 방문하여 파란색 말풍선을 클릭해 지원 센터에 문의해 주세요."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "멤버 UI 표시"
- },
"project.moderation.thread.private-description": {
"message": "이곳은 Modrinth 운영진과의 비공개 대화 스레드입니다. 운영진이 이 프로젝트와 관련된 사항에 대해 메시지를 보낼 수 있습니다."
},
@@ -2894,9 +2891,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, other {해당 버전은}} 현재 보류 상태이며 공개되지 않았습니다. 이 모드팩 {count, plural, other {버전에}} 포함된 특정 파일들을 재배포할 수 있는 권한이 있음을 증명하는 자료를 제출해주세요."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "해결"
- },
"project.versions.withheld-versions-warning.title": {
"message": "알 수 없는 내장 콘텐츠로 인해 일부 {count, plural, other {버전}}은 공개되지 않았습니다"
},
diff --git a/apps/frontend/src/locales/ms-MY/index.json b/apps/frontend/src/locales/ms-MY/index.json
index df1b3fc14a..af66e4aab1 100644
--- a/apps/frontend/src/locales/ms-MY/index.json
+++ b/apps/frontend/src/locales/ms-MY/index.json
@@ -3143,9 +3143,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Jika anda memerlukan bantuan atau mempunyai pertanyaan tambahan, sila lawati
Pusat Bantuan Modrinth dan klik gelembung biru untuk menghubungi sokongan."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "Tunjukkan antara muka ahli"
- },
"project.moderation.thread.private-description": {
"message": "Ini adalah bebenang perbualan peribadi dengan penyederhana Modrinth. Mereka mungkin akan menghantar mesej kepada anda tentang isu-isu berkaitan projek ini."
},
@@ -3272,9 +3269,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, other {Versi ini}} kini ditahan dan tidak disenaraikan secara umum. Sila berikan bukti bahawa anda mempunyai kebenaran untuk mengedarkan semula fail tertentu yang disertakan dalam {count, plural, other {versi}} pek mod ini."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Selesaikan"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, other {Versi}} telah ditahan kerana kandungan terbenam yang tidak diketahui"
},
diff --git a/apps/frontend/src/locales/pl-PL/index.json b/apps/frontend/src/locales/pl-PL/index.json
index c1f79399cf..d790653335 100644
--- a/apps/frontend/src/locales/pl-PL/index.json
+++ b/apps/frontend/src/locales/pl-PL/index.json
@@ -3326,9 +3326,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Jeżeli potrzebujesz pomocy lub masz więcej pytań, odwiedź
centrum pomocy Modrinth i kliknij w niebieskie kółko, by skontaktować się z obsługą."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "Pokaż UI członków"
- },
"project.moderation.thread.private-description": {
"message": "Prywatny wątek konwersacji z moderatorami Modrinth. Mogą skontaktować się z Tobą w razie problemów z Twoim projektem."
},
@@ -3458,9 +3455,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {Ta wersja jest} other {Te wersje są}} obecnie wstrzymane i nie widnieją na publicznej liście. Proszę o przesłanie potwierdzenia posiadania zgody na redystrybucję określonych plików zawartych w {count, plural, one {tej wersji} other {tych wersjach}} modpacka."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Rozwiąż"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, one {Wersja {version_name}} other {Wersje}} wstrzymane ze względu na nieznaną osadzoną zawartość"
},
diff --git a/apps/frontend/src/locales/pt-BR/index.json b/apps/frontend/src/locales/pt-BR/index.json
index ba54c0feae..8e988766ac 100644
--- a/apps/frontend/src/locales/pt-BR/index.json
+++ b/apps/frontend/src/locales/pt-BR/index.json
@@ -3323,9 +3323,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Se precisar de ajuda ou tiver dúvidas adicionais, visite a
Central de Ajuda do Modrinth e clique no balão azul para entrar em contato com o suporte."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "Exibir interface do membro"
- },
"project.moderation.thread.private-description": {
"message": "Este é um tópico de conversa privada com os moderadores do Modrinth. Eles podem entrar em contato com você para tratar de assuntos relacionados a este projeto."
},
@@ -3455,9 +3452,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {Esta versão está atualmente retida e não listada} other {Estas versões estão atualmente retidas e não listadas}} publicamente. Por favor, forneça prova de que você tem permissão para redistribuir certos arquivos incluídos {count, plural, one {na versão} other {nas versões}} do pacote de mods."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Resolver"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural,one {Versão {version_name} retida} other {Versões retidas}} por conteúdo incluso desconhecido"
},
diff --git a/apps/frontend/src/locales/ru-RU/index.json b/apps/frontend/src/locales/ru-RU/index.json
index 1201869a4a..8d96d10f1c 100644
--- a/apps/frontend/src/locales/ru-RU/index.json
+++ b/apps/frontend/src/locales/ru-RU/index.json
@@ -3314,9 +3314,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Если вам нужна помощь или у вас есть дополнительные вопросы, посетите
справочный центр Modrinth и нажмите на синий значок, чтобы обратиться в поддержку."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "Показать интерфейс участника"
- },
"project.moderation.thread.private-description": {
"message": "Это приватная переписка с модераторами Modrinth. Они могут связываться с вами по вопросам, связанным с этим проектом."
},
@@ -3446,9 +3443,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {Эта версия в настоящее время скрыта и не отображается} other {Эти версии в настоящее время скрыты и не отображаются}} в публичном списке. Пожалуйста, предоставьте доказательства того, что у вас есть разрешение на распространение определённых файлов, включённых в {count, plural, one {эту версию} other {эти версии}}."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Решить"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, one {Версия {version_name} скрыта} other {Версии скрыты}} из-за неизвестного встроенного контента"
},
diff --git a/apps/frontend/src/locales/tr-TR/index.json b/apps/frontend/src/locales/tr-TR/index.json
index 6565552306..b445b59b25 100644
--- a/apps/frontend/src/locales/tr-TR/index.json
+++ b/apps/frontend/src/locales/tr-TR/index.json
@@ -3281,9 +3281,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Yardıma ihtiyacınız varsa veya ek sorularınız varsa, lütfen
Modrinth Yardım Merkezi'ni ziyaret edin ve destekle iletişime geçmek için mavi baloncuğa tıklayın."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "Üye kullanıcı arayüzünü göster"
- },
"project.moderation.thread.private-description": {
"message": "Bu, Modrinth moderatörleri ile özel bir konuşma başlığıdır. Bu projeyle ilgili konularda size mesaj gönderebilirler."
},
@@ -3413,9 +3410,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {Bu sürüm} other {Bu sürümler}} şu anda gizlenmiş durumda ve herkese açık olarak listelenmiyor. Lütfen {count, plural, one {sürüm} other {sürümler}} mod paketine dahil edilen belirli dosyaları yeniden dağıtma izniniz olduğuna dair kanıt sunun."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Çöz"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, one {Sürüm {version_name}} other {Sürümler}} bilinmeyen gömülü içerik nedeniyle gizlendi"
},
diff --git a/apps/frontend/src/locales/uk-UA/index.json b/apps/frontend/src/locales/uk-UA/index.json
index e04fc30a58..103ab80f69 100644
--- a/apps/frontend/src/locales/uk-UA/index.json
+++ b/apps/frontend/src/locales/uk-UA/index.json
@@ -3074,9 +3074,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Якщо вам потрібна допомога або у вас є додаткові запитання, відвідайте
довідковий центр Modrinth і натисніть синю підказку, щоб зв’язатися зі службою підтримки."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "Показати інтерфейс учасника"
- },
"project.moderation.thread.private-description": {
"message": "Це тема приватної розмови з модераторами Modrinth. Вони можуть надіслати вам повідомлення про проблеми щодо цього проєкту."
},
@@ -3200,9 +3197,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {Ця версія} few {Ці версії} many {Ці версії} other {Ці версії}} наразі наразі утримано та поза списком. Будь ласка, надайте підтвердження того, що ви маєте дозвіл на повторне розповсюдження певних файлів, які входять до {count, plural, one {версії} few {версій} many {версій} other {версій}} збірки."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Розв'язати"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, one {Версія{version_name}} other {Версії}} утримано через невідомий убудований уміст"
},
diff --git a/apps/frontend/src/locales/vi-VN/index.json b/apps/frontend/src/locales/vi-VN/index.json
index e4912cef22..41163da011 100644
--- a/apps/frontend/src/locales/vi-VN/index.json
+++ b/apps/frontend/src/locales/vi-VN/index.json
@@ -3290,9 +3290,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Nếu bạn cần trợ giúp hoặc có thắc mắc, vui lòng vào
trung tâm trợ giúp và bấm vào nút tin nhắn màu xanh để liên hệ hỗ trợ."
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "Hiển thị giao diện thành viên"
- },
"project.moderation.thread.private-description": {
"message": "Đây là nơi hội thoại riêng tư với bên kiểm duyệt của Modrinth. Họ có thể nhắn cho bạn về những vấn đề trong dự án này."
},
@@ -3422,9 +3419,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, other {Những phiên bản này}} bị giữ lại và không được công khai. Vui lòng đưa ra bằng chứng rằng bạn có quyền để đăng tải một số tệp đưọc bao gồm trong {count, plural, other {các phiên bản}} modpack đó."
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "Khắc phục"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, other{Phiên bản}} bị giữ lại do chứa nội dung nhúng không xác định"
},
diff --git a/apps/frontend/src/locales/zh-CN/index.json b/apps/frontend/src/locales/zh-CN/index.json
index e997519cba..63b52ba912 100644
--- a/apps/frontend/src/locales/zh-CN/index.json
+++ b/apps/frontend/src/locales/zh-CN/index.json
@@ -3284,9 +3284,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "如果你需要帮助或有其他疑问,请访问
Modrinth Help Center ,然后点击蓝色气泡以联系支持。"
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "显示成员图形界面"
- },
"project.moderation.thread.private-description": {
"message": "这是与 Modrinth 管理员的私人对话消息。他们可能会就此项目的问题向你发送消息。"
},
@@ -3416,9 +3413,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, other {此版本}}已保留且未公开列出。请提供你有权重新分发此模组包{count, plural, other {版本}}所包含的某些文件的证明。"
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "完成"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, one {版本 {version_name}} other {这些版本}} 因未知嵌入内容被保留"
},
diff --git a/apps/frontend/src/locales/zh-TW/index.json b/apps/frontend/src/locales/zh-TW/index.json
index f015e154f4..cb94f9a229 100644
--- a/apps/frontend/src/locales/zh-TW/index.json
+++ b/apps/frontend/src/locales/zh-TW/index.json
@@ -3290,9 +3290,6 @@
"project.moderation.admonition.withheld.header": {
"message": "由工作人員設為不公開"
},
- "project.moderation.thread.moderator-see-user-ui-toggle": {
- "message": "顯示成員 UI"
- },
"project.moderation.thread.title": {
"message": "審核訊息"
},
@@ -3419,9 +3416,6 @@
"project.versions.withheld-versions-warning.description": {
"message": "{count, plural, one {這個版本} other {這些版本}}目前被限制存取且不公開。請提供你擁有轉載該模組包{count, plural, other {版本}}版本中特定檔案的許可證明。"
},
- "project.versions.withheld-versions-warning.resolve-button": {
- "message": "解決"
- },
"project.versions.withheld-versions-warning.title": {
"message": "{count, plural, one {版本 {version_name}} other {版本}}因未知的嵌入內容而被限制存取"
},
diff --git a/apps/frontend/src/pages/[type]/[project].vue b/apps/frontend/src/pages/[type]/[project].vue
index c1ad655e77..ddfc9312c2 100644
--- a/apps/frontend/src/pages/[type]/[project].vue
+++ b/apps/frontend/src/pages/[type]/[project].vue
@@ -881,7 +881,9 @@
class="card flex-card"
/>
{{ formatMessage(messages.threadSectionTitle) }}
-
+
-
- {{ formatMessage(messages.moderatorSeeUserUiToggle) }}
-
+ Show member UI
@@ -151,8 +149,9 @@ import {
Toggle,
useVIntl,
} from '@modrinth/ui'
+import { isStaff } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
-import { computed, type Ref, watch } from 'vue'
+import { computed, watch } from 'vue'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers/projects.js'
@@ -160,7 +159,6 @@ import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
-type ProjectPageMember = Labrinth.Projects.v3.TeamMember & { staffOnly?: boolean }
type ModerationAdmonitionSection =
| {
type: 'paragraph'
@@ -181,10 +179,6 @@ const messages = defineMessages({
id: 'project.moderation.thread.title',
defaultMessage: 'Moderation messages',
},
- moderatorSeeUserUiToggle: {
- id: 'project.moderation.thread.moderator-see-user-ui-toggle',
- defaultMessage: 'Show member UI',
- },
threadPrivateDescription: {
id: 'project.moderation.thread.private-description',
defaultMessage:
@@ -213,18 +207,10 @@ const messages = defineMessages({
})
const { addNotification } = injectNotificationManager()
-const {
- projectV2: project,
- currentMember: currentMemberRaw,
- invalidate,
- allMembers,
-} = injectProjectPageContext()
-const currentMember = currentMemberRaw as Ref
+const { projectV2: project, currentMember, invalidate, allMembers } = injectProjectPageContext()
const canAccess = computed(() => !!currentMember.value)
-const userFacingUiVisible = computed(
- () => !!currentMember.value && (!currentMember.value.staffOnly || moderatorSeeUserUi.value),
-)
+const userFacingUiVisible = computed(() => !!currentMember.value && moderatorSeeUserUi.value)
const approvedAdmonitionMessage = computed(() => {
switch (project.value?.status) {
diff --git a/apps/frontend/src/pages/[type]/[project]/settings.vue b/apps/frontend/src/pages/[type]/[project]/settings.vue
index 45ea37ca34..96b7886fd9 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings.vue
@@ -17,6 +17,7 @@ import {
commonMessages,
commonProjectSettingsMessages,
injectProjectPageContext,
+ Toggle,
useVIntl,
} from '@modrinth/ui'
import { isStaff } from '@modrinth/utils'
@@ -47,10 +48,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 +81,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),
@@ -144,6 +143,16 @@ watch(route, () => {
const scrollY = scroll.y.value
setTimeout(() => window.scrollTo(0, scrollY), 10)
})
+
+const moderatorSeeUserUi = computed({
+ get() {
+ return flags.value.showModeratorProjectMemberUi
+ },
+ set(value: boolean) {
+ flags.value.showModeratorProjectMemberUi = value
+ saveFeatureFlags()
+ },
+})
@@ -167,6 +176,10 @@ watch(route, () => {
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/permissions.vue b/apps/frontend/src/pages/[type]/[project]/settings/permissions.vue
index 2d50a45c5d..aeb1acdb88 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/permissions.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/permissions.vue
@@ -1,51 +1,303 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
- {{ currentSortType }}
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ currentSortLabel }}
+
+
+
+
+
+
+
+
+ {{ expandCollapseAllLabel }}
+
+
+
+
+
+ {{ noPermissionCount }} with no permission
+
+ •
+
+
+
+ {{ pendingCount }} need user action
+
+ •
+
+
+ {{ pendingApprovalCount }} awaiting review
+
+
+ None awaiting review
+
+
-
+
+
+
+
+
+
+ {{ attributionError }}
+
+
+
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/versions.vue b/apps/frontend/src/pages/[type]/[project]/settings/versions.vue
index 25b29c2e5d..b64ee956a5 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()"
/>
-
@@ -40,7 +41,8 @@
project.slug ? project.slug : project.id
}/settings/permissions`"
>
- {{ formatMessage(messages.withheldVersionsWarningResolve) }}
+ {{ formatMessage(commonProjectSettingsMessages.withheldVersionsWarningResolve) }}
+
@@ -333,6 +335,7 @@ import {
import {
Admonition,
ButtonStyled,
+ commonProjectSettingsMessages,
ConfirmModal,
defineMessages,
injectModrinthClient,
@@ -458,7 +461,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: {
@@ -471,9 +476,5 @@ const messages = defineMessages({
defaultMessage:
'{count, plural, one {This version is} other {These versions are}} currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included in the modpack {count, plural, one {version} other {versions}}.',
},
- withheldVersionsWarningResolve: {
- id: 'project.versions.withheld-versions-warning.resolve-button',
- defaultMessage: 'Resolve',
- },
})
diff --git a/apps/frontend/src/pages/[type]/[project]/version/[version].vue b/apps/frontend/src/pages/[type]/[project]/version/[version].vue
index 20a4737f86..ada828c445 100644
--- a/apps/frontend/src/pages/[type]/[project]/version/[version].vue
+++ b/apps/frontend/src/pages/[type]/[project]/version/[version].vue
@@ -1,789 +1,592 @@
-
-
+
+
-
-
-
-
- Package your data pack as a mod. This will create a new version with support for the
- selected mod loaders. You will be redirected to the new version and can edit it to your
- liking.
-
-
-
- Mod loaders
-
- The mod loaders you would like to package your data pack for.
-
+
+
+
+ {{ formatMessage(messages.packageDataPackDescription) }}
+
+
+ {{
+ formatMessage(messages.modLoadersLabel)
+ }}
+ {{ formatMessage(messages.modLoadersDescription) }}
-
-
-
-
-
-
-
- Your version must have a version number.
-
- Your version must have the supported Minecraft versions selected.
-
-
- Your version must have a file uploaded.
-
-
- Your version must have the supported mod loaders selected.
-
-
-
-
-
-
-
-
- Save
-
-
-
-
-
- Feature version (deprecated)
- Unfeature version (deprecated)
-
-
-
-
-
- Discard changes
-
-
-
-
-
-
-
-
Dependencies
-
-
Loading dependencies...
-
-
-
+
+
+ {{ formatMessage(messages.allVersions) }}
+
+
+
-
-
-
- {{ dependency.project ? dependency.project.title : 'Unknown Project' }}
-
-
- Version {{ dependency.version.version_number }} is
- {{ dependency.dependency_type }}
-
-
- {{ dependency.dependency_type }}
-
-
-
-
- {{ dependency.project ? dependency.project.title : 'Unknown Project' }}
-
-
- Version {{ dependency.version.version_number }} is
- {{ dependency.dependency_type }}
-
-
- {{ dependency.dependency_type }}
-
-
-
-
-
- Remove
-
-
-
-
-
+
+
+
+ {{ formatMessage(commonProjectSettingsMessages.withheldVersionsWarningResolve) }}
+
+
+
+
+
+
+
-
-
-
- {{ dependency.file_name }}
-
- Added via overrides
-
-
-
-
-
-
Files
-
-
-
- {{ file.filename }}
- ({{ formatBytes(file.size) }})
-
- Primary
-
-
- Required resource pack
-
-
- Optional resource pack
-
-
-
-
-
- Download
-
-
-
-
-
-
diff --git a/apps/frontend/src/pages/report.vue b/apps/frontend/src/pages/report.vue
index 59af8701e6..461ac3fed5 100644
--- a/apps/frontend/src/pages/report.vue
+++ b/apps/frontend/src/pages/report.vue
@@ -284,11 +284,11 @@ import {
VersionIcon,
XCircleIcon,
} from '@modrinth/assets'
-import { defineMessage } from '@modrinth/ui'
import {
AutoLink,
Avatar,
ButtonStyled,
+ defineMessage,
defineMessages,
formatReportItemType,
injectNotificationManager,
diff --git a/apps/frontend/src/pages/user/[user].vue b/apps/frontend/src/pages/user/[user].vue
index a590bccc27..8a5bdd4d1b 100644
--- a/apps/frontend/src/pages/user/[user].vue
+++ b/apps/frontend/src/pages/user/[user].vue
@@ -133,7 +133,7 @@
{{ user.username }}
)
const isModrinthUser = computed(() => user.value?.id === '2REoufqX')
+const isAutoMod = computed(() => user.value?.id === '')
+const isOfficialAccount = computed(
+ () => isModrinthUser.value || isAutoMod.value || user.value?.id === 'GVFjtWTf',
+)
const sortedCollections = computed(() => {
const list = collections.value
diff --git a/apps/frontend/src/providers/setup/attribution-moderation.ts b/apps/frontend/src/providers/setup/attribution-moderation.ts
new file mode 100644
index 0000000000..944f18040d
--- /dev/null
+++ b/apps/frontend/src/providers/setup/attribution-moderation.ts
@@ -0,0 +1,6 @@
+import { attributionQuickReplies } from '@modrinth/moderation'
+import { provideAttributionModeration } from '@modrinth/ui'
+
+export function setupAttributionModerationProvider() {
+ provideAttributionModeration({ attributionQuickReplies })
+}
diff --git a/apps/frontend/src/providers/version/manage-version-modal.ts b/apps/frontend/src/providers/version/manage-version-modal.ts
index 51b98c4942..34397fcf64 100644
--- a/apps/frontend/src/providers/version/manage-version-modal.ts
+++ b/apps/frontend/src/providers/version/manage-version-modal.ts
@@ -3,9 +3,11 @@ import { SaveIcon, SpinnerIcon } from '@modrinth/assets'
import {
type ComboboxOption,
createContext,
+ defineMessage,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
+ type MessageDescriptor,
type MultiStageModal,
resolveCtxFn,
type StageButtonConfig,
@@ -164,6 +166,44 @@ export const fileTypeLabels: Record = {
+ primary: defineMessage({
+ id: 'version.file-type.primary',
+ defaultMessage: 'Primary',
+ }),
+ unknown: defineMessage({
+ id: 'version.file-type.unknown',
+ defaultMessage: 'Other',
+ }),
+ 'required-resource-pack': defineMessage({
+ id: 'version.file-type.required-resource-pack',
+ defaultMessage: 'Required resource pack',
+ }),
+ 'optional-resource-pack': defineMessage({
+ id: 'version.file-type.optional-resource-pack',
+ defaultMessage: 'Optional resource pack',
+ }),
+ 'sources-jar': defineMessage({
+ id: 'version.file-type.sources-jar',
+ defaultMessage: 'Sources jar',
+ }),
+ 'dev-jar': defineMessage({
+ id: 'version.file-type.dev-jar',
+ defaultMessage: 'Dev jar',
+ }),
+ 'javadoc-jar': defineMessage({
+ id: 'version.file-type.javadoc-jar',
+ defaultMessage: 'Javadoc jar',
+ }),
+ signature: defineMessage({
+ id: 'version.file-type.signature',
+ defaultMessage: 'Signature file',
+ }),
+}
+
export const [injectManageVersionContext, provideManageVersionContext] =
createContext('CreateProjectVersionModal')
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-0dda0265b39d4c8b019eb1ea6d164ee639a6deb0f47c1dcdd83a5816308faab0.json b/apps/labrinth/.sqlx/query-0dda0265b39d4c8b019eb1ea6d164ee639a6deb0f47c1dcdd83a5816308faab0.json
new file mode 100644
index 0000000000..b0124573bd
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-0dda0265b39d4c8b019eb1ea6d164ee639a6deb0f47c1dcdd83a5816308faab0.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 attribution_enforced_versions aev on aev.id = f.version_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": "0dda0265b39d4c8b019eb1ea6d164ee639a6deb0f47c1dcdd83a5816308faab0"
+}
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-623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3.json b/apps/labrinth/.sqlx/query-16e13baf35118cda943abf259a0e24cfe13436844b7909e6502ec15a249fc856.json
similarity index 56%
rename from apps/labrinth/.sqlx/query-623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3.json
rename to apps/labrinth/.sqlx/query-16e13baf35118cda943abf259a0e24cfe13436844b7909e6502ec15a249fc856.json
index 6ad1c4b9b5..d33825a8c3 100644
--- a/apps/labrinth/.sqlx/query-623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3.json
+++ b/apps/labrinth/.sqlx/query-16e13baf35118cda943abf259a0e24cfe13436844b7909e6502ec15a249fc856.json
@@ -1,30 +1,35 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT DISTINCT dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type\n FROM dependencies d\n WHERE dependent_id = ANY($1)\n ",
+ "query": "\n SELECT DISTINCT d.id as dependency_id, dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type\n FROM dependencies d\n WHERE dependent_id = ANY($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
+ "name": "dependency_id",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 1,
"name": "version_id",
"type_info": "Int8"
},
{
- "ordinal": 1,
+ "ordinal": 2,
"name": "dependency_project_id",
"type_info": "Int8"
},
{
- "ordinal": 2,
+ "ordinal": 3,
"name": "dependency_version_id",
"type_info": "Int8"
},
{
- "ordinal": 3,
+ "ordinal": 4,
"name": "file_name",
"type_info": "Varchar"
},
{
- "ordinal": 4,
+ "ordinal": 5,
"name": "dependency_type",
"type_info": "Varchar"
}
@@ -35,6 +40,7 @@
]
},
"nullable": [
+ false,
false,
true,
true,
@@ -42,5 +48,5 @@
false
]
},
- "hash": "623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3"
+ "hash": "16e13baf35118cda943abf259a0e24cfe13436844b7909e6502ec15a249fc856"
}
diff --git a/apps/labrinth/.sqlx/query-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-2055c95a568c9159849b10fa94fd6682fb4edf05d80d9b5c17c597d8807f1346.json b/apps/labrinth/.sqlx/query-2055c95a568c9159849b10fa94fd6682fb4edf05d80d9b5c17c597d8807f1346.json
new file mode 100644
index 0000000000..48c9c6bafe
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-2055c95a568c9159849b10fa94fd6682fb4edf05d80d9b5c17c597d8807f1346.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tdelete from project_attribution_files paf\n\t\tusing project_attribution_groups pag\n\t\twhere pag.id = paf.group_id\n\t\t\tand pag.project_id = $1\n\t\t\tand paf.sha1 = $2\n\t\t\tand paf.group_id != $3\n\t\t",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Bytea",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "2055c95a568c9159849b10fa94fd6682fb4edf05d80d9b5c17c597d8807f1346"
+}
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-262070fb8ef00ba6e7c7e7b3b5262e959448892945bf56117ba74446808cea76.json b/apps/labrinth/.sqlx/query-262070fb8ef00ba6e7c7e7b3b5262e959448892945bf56117ba74446808cea76.json
new file mode 100644
index 0000000000..b5d39609ad
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-262070fb8ef00ba6e7c7e7b3b5262e959448892945bf56117ba74446808cea76.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tselect attribution, project_id as \"project_id: DBProjectId\"\n\t\tfrom project_attribution_groups\n\t\twhere id = $1\n\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "attribution",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 1,
+ "name": "project_id: DBProjectId",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": [
+ true,
+ false
+ ]
+ },
+ "hash": "262070fb8ef00ba6e7c7e7b3b5262e959448892945bf56117ba74446808cea76"
+}
diff --git a/apps/labrinth/.sqlx/query-2ecca6dd46f1d4cc99745a8ab6780701a07444b2b658f2f7e8af94f78411bab6.json b/apps/labrinth/.sqlx/query-2ecca6dd46f1d4cc99745a8ab6780701a07444b2b658f2f7e8af94f78411bab6.json
new file mode 100644
index 0000000000..bd96a7e60a
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-2ecca6dd46f1d4cc99745a8ab6780701a07444b2b658f2f7e8af94f78411bab6.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 on conflict (group_id, sha1) do update\n set moderation_external_license_id = excluded.moderation_external_license_id\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Text",
+ "Bytea",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "2ecca6dd46f1d4cc99745a8ab6780701a07444b2b658f2f7e8af94f78411bab6"
+}
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-428bef57672572cfe1f308701d17844412e6b25a06b1ec530a18f5ad11274bd2.json b/apps/labrinth/.sqlx/query-428bef57672572cfe1f308701d17844412e6b25a06b1ec530a18f5ad11274bd2.json
new file mode 100644
index 0000000000..05f2bf2074
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-428bef57672572cfe1f308701d17844412e6b25a06b1ec530a18f5ad11274bd2.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 on conflict (group_id, sha1) do nothing\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "TextArray",
+ "ByteaArray"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "428bef57672572cfe1f308701d17844412e6b25a06b1ec530a18f5ad11274bd2"
+}
diff --git a/apps/labrinth/.sqlx/query-43ce7cbf44ce123c3d71c5dee035022d2e6b537eb53fcb39608e820e77174b81.json b/apps/labrinth/.sqlx/query-43ce7cbf44ce123c3d71c5dee035022d2e6b537eb53fcb39608e820e77174b81.json
new file mode 100644
index 0000000000..77eab0d2fb
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-43ce7cbf44ce123c3d71c5dee035022d2e6b537eb53fcb39608e820e77174b81.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tinsert into file_scans (file_id)\n\t\tselect f.id\n\t\tfrom files f\n\t\tinner join attribution_enforced_versions aev on aev.id = f.version_id\n\t\twhere f.version_id = any($1)\n\t\ton conflict (file_id) do nothing\n\t\t",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "43ce7cbf44ce123c3d71c5dee035022d2e6b537eb53fcb39608e820e77174b81"
+}
diff --git a/apps/labrinth/.sqlx/query-48a83bc9de9bea73eac5c43e71f43fb8a1ac7eb67079d16e3de92f5ff6bf72b8.json b/apps/labrinth/.sqlx/query-48a83bc9de9bea73eac5c43e71f43fb8a1ac7eb67079d16e3de92f5ff6bf72b8.json
new file mode 100644
index 0000000000..dac0ac1f3f
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-48a83bc9de9bea73eac5c43e71f43fb8a1ac7eb67079d16e3de92f5ff6bf72b8.json
@@ -0,0 +1,47 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\t\tselect\n\t\t\t\tpaf.group_id as \"group_id!\",\n\t\t\t\tpaf.name as \"name!\",\n\t\t\t\tconvert_from(paf.sha1, 'UTF8') as \"sha1!\",\n\t\t\t\tpaf.moderation_external_license_id,\n\t\t\t\tcoalesce(array_agg(distinct aev.id) filter (where aev.id is not null), '{}') as \"version_ids!: Vec\"\n\t\t\tfrom project_attribution_files paf\n\t\t\tleft join override_file_sources ofs on ofs.sha1 = paf.sha1\n\t\t\tleft join files f on f.id = ofs.file_id\n\t\t\tleft join versions v on v.id = f.version_id and v.mod_id = $2\n\t\t\tleft join attribution_enforced_versions aev on aev.id = v.id\n\t\t\twhere paf.group_id = ANY($1)\n\t\t\tgroup by paf.group_id, paf.name, paf.sha1, paf.moderation_external_license_id\n\t\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "group_id!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "name!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "sha1!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "moderation_external_license_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 4,
+ "name": "version_ids!: Vec",
+ "type_info": "Int8Array"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ null,
+ true,
+ null
+ ]
+ },
+ "hash": "48a83bc9de9bea73eac5c43e71f43fb8a1ac7eb67079d16e3de92f5ff6bf72b8"
+}
diff --git a/apps/labrinth/.sqlx/query-5394780f3fadc02e56bf904718ab1607eb89f5ac6fd8531404c8274f4ce319ef.json b/apps/labrinth/.sqlx/query-5394780f3fadc02e56bf904718ab1607eb89f5ac6fd8531404c8274f4ce319ef.json
new file mode 100644
index 0000000000..659c50f4ba
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-5394780f3fadc02e56bf904718ab1607eb89f5ac6fd8531404c8274f4ce319ef.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 on conflict (group_id, sha1) do nothing\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Text",
+ "Bytea"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "5394780f3fadc02e56bf904718ab1607eb89f5ac6fd8531404c8274f4ce319ef"
+}
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-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-86e59f16918c0197b4e1492d3f72e191a8deb05f7753743f48f1b6647f83f544.json b/apps/labrinth/.sqlx/query-86e59f16918c0197b4e1492d3f72e191a8deb05f7753743f48f1b6647f83f544.json
new file mode 100644
index 0000000000..77323e7c62
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-86e59f16918c0197b4e1492d3f72e191a8deb05f7753743f48f1b6647f83f544.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 versions v on v.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 pag.project_id = v.mod_id\n and (\n pag.attribution is null\n or pag.attribution->>'kind' = 'no_permission'\n or coalesce(\n pag.attribution->'moderation_status'->>'kind',\n 'approved'\n ) != 'approved'\n )\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "version_id: DBVersionId",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "file_id: DBFileId",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 2,
+ "name": "flame_project",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true
+ ]
+ },
+ "hash": "86e59f16918c0197b4e1492d3f72e191a8deb05f7753743f48f1b6647f83f544"
+}
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-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-a39aff314fd586a0d5bf275482e7034711b7c168757f77c33274ce93a385b324.json b/apps/labrinth/.sqlx/query-a39aff314fd586a0d5bf275482e7034711b7c168757f77c33274ce93a385b324.json
new file mode 100644
index 0000000000..dd6f959259
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-a39aff314fd586a0d5bf275482e7034711b7c168757f77c33274ce93a385b324.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tinsert into project_attribution_files (\n\t\t\tgroup_id,\n\t\t\tname,\n\t\t\tsha1,\n\t\t\tmoderation_external_license_id\n\t\t)\n\t\tselect\n\t\t\t$1,\n\t\t\tpaf.name,\n\t\t\tpaf.sha1,\n\t\t\tpaf.moderation_external_license_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 = $2 and pag.project_id = $3\n\t\torder by paf.moderation_external_license_id nulls last, paf.name\n\t\tlimit 1\n\t\ton conflict (group_id, sha1) do update\n\t\tset moderation_external_license_id = coalesce(\n\t\t\tproject_attribution_files.moderation_external_license_id,\n\t\t\texcluded.moderation_external_license_id\n\t\t)\n\t\t",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Bytea",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "a39aff314fd586a0d5bf275482e7034711b7c168757f77c33274ce93a385b324"
+}
diff --git a/apps/labrinth/.sqlx/query-adef48e6edd40766c05946e1cb71bc6c7700c31726c4f4f39a1bdcdc4f84e1fd.json b/apps/labrinth/.sqlx/query-adef48e6edd40766c05946e1cb71bc6c7700c31726c4f4f39a1bdcdc4f84e1fd.json
new file mode 100644
index 0000000000..7109167038
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-adef48e6edd40766c05946e1cb71bc6c7700c31726c4f4f39a1bdcdc4f84e1fd.json
@@ -0,0 +1,40 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n select\n d.id as \"dependency_id!\",\n pag.attribution,\n pag.flame_project,\n pag.project_id as \"project_id: DBProjectId\"\n from dependencies d\n inner join files f on f.version_id = d.dependent_id\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join versions v on v.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 pag.project_id = v.mod_id\n and d.dependency_file_name is not null\n and (\n pag.flame_project is not null\n or pag.attribution is not null\n )\n and split_part(paf.name, '/', -1) = d.dependency_file_name\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "dependency_id!",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 1,
+ "name": "attribution",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 2,
+ "name": "flame_project",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 3,
+ "name": "project_id: DBProjectId",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ true,
+ false
+ ]
+ },
+ "hash": "adef48e6edd40766c05946e1cb71bc6c7700c31726c4f4f39a1bdcdc4f84e1fd"
+}
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-b774f5cce4d76032d85a986e7905695c98e6175a134f105eb53495bfce4399be.json b/apps/labrinth/.sqlx/query-b774f5cce4d76032d85a986e7905695c98e6175a134f105eb53495bfce4399be.json
new file mode 100644
index 0000000000..3df2b8beef
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-b774f5cce4d76032d85a986e7905695c98e6175a134f105eb53495bfce4399be.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 on conflict (group_id, sha1) do update\n set moderation_external_license_id = excluded.moderation_external_license_id\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Text",
+ "Bytea",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "b774f5cce4d76032d85a986e7905695c98e6175a134f105eb53495bfce4399be"
+}
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-cd29f5f072e102f9286869081b387eca5cee6b78f175320cbd615ce216e39171.json b/apps/labrinth/.sqlx/query-cd29f5f072e102f9286869081b387eca5cee6b78f175320cbd615ce216e39171.json
new file mode 100644
index 0000000000..0180b54374
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-cd29f5f072e102f9286869081b387eca5cee6b78f175320cbd615ce216e39171.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n INSERT INTO file_scans (file_id)\n SELECT $1\n WHERE EXISTS (\n SELECT 1\n FROM attribution_enforced_versions\n WHERE id = $2\n )\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "cd29f5f072e102f9286869081b387eca5cee6b78f175320cbd615ce216e39171"
+}
diff --git a/apps/labrinth/.sqlx/query-ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa.json b/apps/labrinth/.sqlx/query-ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa.json
new file mode 100644
index 0000000000..9d4ffdf994
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa.json
@@ -0,0 +1,29 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tselect paf.group_id, paf.name from project_attribution_files paf\n\t\tinner join project_attribution_groups pag on pag.id = paf.group_id\n\t\twhere paf.sha1 = $1 and pag.project_id = $2\n\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "group_id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "name",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa"
+}
diff --git a/apps/labrinth/.sqlx/query-dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9.json b/apps/labrinth/.sqlx/query-dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9.json
new file mode 100644
index 0000000000..8565cb6fbd
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9.json
@@ -0,0 +1,40 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\t\tselect id, name, version_number, date_published\n\t\t\tfrom versions\n\t\t\twhere id = ANY($1)\n\t\t\torder by date_published desc\n\t\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "name",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "version_number",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "date_published",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9"
+}
diff --git a/apps/labrinth/.sqlx/query-df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375.json b/apps/labrinth/.sqlx/query-df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375.json
new file mode 100644
index 0000000000..2456b3554f
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375.json
@@ -0,0 +1,17 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n insert into project_attribution_groups (id, project_id, attribution, flame_project)\n values ($1, $2, $3, $4)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Int8",
+ "Jsonb",
+ "Jsonb"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375"
+}
diff --git a/apps/labrinth/.sqlx/query-e4b58fe6e5d19ded48ab9e457b4e96fb51c8f74a7532bb9ab2e7a945b95bbe78.json b/apps/labrinth/.sqlx/query-e4b58fe6e5d19ded48ab9e457b4e96fb51c8f74a7532bb9ab2e7a945b95bbe78.json
new file mode 100644
index 0000000000..91a7a9c5c8
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-e4b58fe6e5d19ded48ab9e457b4e96fb51c8f74a7532bb9ab2e7a945b95bbe78.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n select paf.sha1 from project_attribution_files paf\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where pag.project_id = $1 and paf.sha1 = ANY($2)\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "sha1",
+ "type_info": "Bytea"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "ByteaArray"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "e4b58fe6e5d19ded48ab9e457b4e96fb51c8f74a7532bb9ab2e7a945b95bbe78"
+}
diff --git a/apps/labrinth/.sqlx/query-e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb.json b/apps/labrinth/.sqlx/query-e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb.json
new file mode 100644
index 0000000000..0b7cf69e9c
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n insert into override_file_sources (sha1, file_id)\n select unnest($1::bytea[]), $2\n on conflict do nothing\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "ByteaArray",
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb"
+}
diff --git a/apps/labrinth/.sqlx/query-e88ab785893c4ea26e633eef766e7259623eb4323337a480d99f5448310e65eb.json b/apps/labrinth/.sqlx/query-e88ab785893c4ea26e633eef766e7259623eb4323337a480d99f5448310e65eb.json
new file mode 100644
index 0000000000..5dfb28c854
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-e88ab785893c4ea26e633eef766e7259623eb4323337a480d99f5448310e65eb.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n\t\tselect\n\t\t\tid as \"id: DBVersionId\",\n\t\t\tmod_id as \"project_id: DBProjectId\"\n\t\tfrom versions\n\t\twhere id = any($1)\n\t\t",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id: DBVersionId",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 1,
+ "name": "project_id: DBProjectId",
+ "type_info": "Int8"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8Array"
+ ]
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "e88ab785893c4ea26e633eef766e7259623eb4323337a480d99f5448310e65eb"
+}
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 b/apps/labrinth/AGENTS.md
index 8cd2edd6c8..6398fdfbc7 100644
--- a/apps/labrinth/AGENTS.md
+++ b/apps/labrinth/AGENTS.md
@@ -6,7 +6,7 @@
- `ApiError::Request` instead of `ApiError::InvalidInput`
- `ApiError::Auth` instead of `ApiError::CustomAuthentication`
- `ApiError::Internal` for database errors, 3rd party service errors, anything else internal
- - Use `eyre!` to construct a value for `Internal` and `Request` variants
+ - Use `eyre!` to construct a value for `Internal`, `Request`, and `Auth` variants
- Error messages (both for errors and exceptions) must be formatted as per the Rust API guidelines:
- lowercase message
- no trailing punctuation
@@ -29,3 +29,6 @@
- You can force a search reindex by:
- Running `cd apps/labrinth && cargo run -p labrinth -- --run-background-task index-search` (prefer this if backend is running locally)
- Hitting the force reindex admin endpoint
+- To seed the database locally: `psql postgresql://labrinth:labrinth@localhost/labrinth -f apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql
+`
+- When writing `sqlx` queries, prefer `r#` raw strings over escaping quotes
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..9007b0aa43
--- /dev/null
+++ b/apps/labrinth/migrations/20260521120000_project_attribution_external_license.sql
@@ -0,0 +1,36 @@
+alter table project_attribution_files
+ add column moderation_external_license_id bigint references moderation_external_licenses(id);
+
+create table attributions_exemptions (
+ version_id bigint references versions(id) on delete cascade,
+ project_id bigint references mods(id) on delete cascade,
+ check ((version_id is null) != (project_id is null))
+);
+
+create unique index attributions_exemptions_version_id_idx
+ on attributions_exemptions (version_id)
+ where version_id is not null;
+
+create unique index attributions_exemptions_project_id_idx
+ on attributions_exemptions (project_id)
+ where project_id is not null;
+
+create view attribution_enforced_versions as
+select v.id
+from versions v
+where not exists (
+ select 1
+ from attributions_exemptions ae
+ where ae.version_id = v.id or ae.project_id = v.mod_id
+);
+
+-- grandfathering migration:
+-- insert into attributions_exemptions (version_id)
+-- select id
+-- from versions
+-- on conflict do nothing;
+--
+-- insert into attributions_exemptions (project_id)
+-- select id
+-- from mods
+-- on conflict do nothing;
diff --git a/apps/labrinth/migrations/20260623120000_unique_project_attribution_files.sql b/apps/labrinth/migrations/20260623120000_unique_project_attribution_files.sql
new file mode 100644
index 0000000000..2c026a6544
--- /dev/null
+++ b/apps/labrinth/migrations/20260623120000_unique_project_attribution_files.sql
@@ -0,0 +1,20 @@
+alter table project_attribution_files
+ add constraint project_attribution_files_group_id_sha1_key unique (group_id, sha1);
+
+-- manually run:
+--
+-- delete from project_attribution_files paf
+-- using (
+-- select ctid
+-- from (
+-- select
+-- ctid,
+-- row_number() over (
+-- partition by group_id, sha1
+-- order by moderation_external_license_id nulls last, name
+-- ) as row_number
+-- from project_attribution_files
+-- ) duplicates
+-- where row_number > 1
+-- ) duplicates
+-- where paf.ctid = duplicates.ctid;
diff --git a/apps/labrinth/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs
index 329e071042..970f9ab7b2 100644
--- a/apps/labrinth/src/auth/checks.rs
+++ b/apps/labrinth/src/auth/checks.rs
@@ -5,11 +5,39 @@ use crate::database::models::version_item::VersionQueryResult;
use crate::database::models::{DBCollection, DBOrganization, DBTeamMember};
use crate::database::redis::RedisPool;
use crate::database::{DBProject, DBVersion, models};
+use crate::models::ids::FileId;
+use crate::models::projects::{
+ MissingAttributionFile, OverrideSource, Version,
+};
use crate::models::users::User;
+use crate::queue::file_scan::{
+ get_dependency_attributions, get_files_missing_attribution,
+};
use crate::routes::ApiError;
use futures::TryStreamExt;
use itertools::Itertools;
+pub async fn enrich_dependency_attributions(
+ versions: &mut [VersionQueryResult],
+ pool: &PgPool,
+) {
+ let version_ids = versions.iter().map(|v| v.inner.id).collect::>();
+ let dep_attr = get_dependency_attributions(pool, &version_ids)
+ .await
+ .unwrap_or_default();
+
+ for version in versions {
+ for dep in &mut version.dependencies {
+ if let Some(attr) = dep_attr.get(&dep.id)
+ && (attr.attribution.flame_project.is_some()
+ || attr.attribution.resolution.is_some())
+ {
+ dep.attribution = Some(attr.attribution.clone());
+ }
+ }
+ }
+}
+
pub trait ValidateAuthorized {
fn validate_authorized(
&self,
@@ -204,7 +232,42 @@ pub async fn filter_visible_versions(
)
.await?;
versions.retain(|x| filtered_version_ids.contains(&x.inner.id));
- Ok(versions.into_iter().map(|x| x.into()).collect())
+
+ let version_ids: Vec<_> = versions.iter().map(|v| v.inner.id).collect();
+ let missing = get_files_missing_attribution(pool, &version_ids)
+ .await
+ .unwrap_or_default();
+
+ enrich_dependency_attributions(&mut versions, pool).await;
+
+ Ok(versions
+ .into_iter()
+ .map(|v| {
+ let files_missing = missing
+ .get(&v.inner.id)
+ .map(|entries| {
+ entries
+ .iter()
+ .map(|(id, fp)| MissingAttributionFile {
+ id: FileId(id.0 as u64),
+ override_source: fp
+ .as_ref()
+ .map(|p| OverrideSource::Flame {
+ id: p.id,
+ title: p.title.clone(),
+ url: p.url.clone(),
+ icon_url: p.icon_url.clone(),
+ })
+ .or(Some(OverrideSource::Unknown)),
+ })
+ .collect::>()
+ })
+ .unwrap_or_default();
+ let mut version = Version::from(v);
+ version.files_missing_attribution = files_missing;
+ version
+ })
+ .collect())
}
impl ValidateAuthorized for models::DBOAuthClient {
@@ -258,13 +321,20 @@ pub async fn filter_visible_version_ids(
filter_enlisted_version_ids(versions.clone(), user_option, pool, redis)
.await?;
+ let version_ids: Vec<_> = versions.iter().map(|v| v.id).collect();
+ let withheld_versions = get_files_missing_attribution(pool, &version_ids)
+ .await
+ .unwrap_or_default();
+
// Return versions that are not hidden, we are a mod of, or we are enlisted on the team of
for version in versions {
+ let is_withheld = withheld_versions.contains_key(&version.id);
// We can see the version if:
- // - it's not hidden and we can see the project
+ // - it's not hidden and we can see the project and it's not withheld for attribution
// - we are a mod
// - we are enlisted on the team of the mod
if (!version.status.is_hidden()
+ && !is_withheld
&& visible_project_ids.contains(&version.project_id))
|| user_option.as_ref().is_some_and(|x| x.role.is_mod())
|| enlisted_version_ids.contains(&version.id)
diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs
index 08f0cf914c..688fe84b9d 100644
--- a/apps/labrinth/src/background_task.rs
+++ b/apps/labrinth/src/background_task.rs
@@ -3,10 +3,12 @@ use crate::database::PgPool;
use crate::database::models::ids::DBUserId;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::redis::RedisPool;
+use crate::file_hosting::FileHost;
use crate::models::notifications::NotificationBody;
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,
@@ -38,6 +40,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,
/// Queues Discord Creator Club role claim emails for newly eligible users.
DiscordRoleEmailCampaign,
}
@@ -50,6 +56,7 @@ impl BackgroundTask {
ro_pool: PgPool,
redis_pool: RedisPool,
search_backend: web::Data,
+ file_host: web::Data,
kafka_client: web::Data,
clickhouse: clickhouse::Client,
stripe_client: stripe::Client,
@@ -112,6 +119,7 @@ impl BackgroundTask {
)
.await
}
+ ScanFiles => scan_all_files(&pool, &redis_pool, &**file_host).await,
DiscordRoleEmailCampaign => {
discord_role_email_campaign(pool, redis_pool).await
}
diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs
index 1ebb09b27d..5e585362a1 100644
--- a/apps/labrinth/src/database/models/ids.rs
+++ b/apps/labrinth/src/database/models/ids.rs
@@ -1,12 +1,13 @@
use super::DatabaseError;
use crate::database::PgTransaction;
use crate::models::ids::{
- AffiliateCodeId, AnalyticsEventId, CampaignDonationId, ChargeId,
- CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId,
- OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId,
- OrganizationId, PatId, PayoutId, ProductId, ProductPriceId, ProjectId,
- ReportId, SessionId, SharedInstanceId, SharedInstanceVersionId, TeamId,
- TeamMemberId, ThreadId, ThreadMessageId, UserSubscriptionId, VersionId,
+ AffiliateCodeId, AnalyticsEventId, AttributionGroupId, CampaignDonationId,
+ ChargeId, CollectionId, FileId, ImageId, NotificationId,
+ OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId,
+ OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId,
+ ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId,
+ SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId, ThreadMessageId,
+ UserSubscriptionId, VersionId,
};
use ariadne::ids::base62_impl::to_base62;
use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range};
@@ -172,6 +173,10 @@ db_id_interface!(
CollectionId,
generator: generate_collection_id @ "collections",
);
+db_id_interface!(
+ AttributionGroupId,
+ generator: generate_attribution_group_id @ "project_attribution_groups",
+);
db_id_interface!(
FileId,
generator: generate_file_id @ "files",
diff --git a/apps/labrinth/src/database/models/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..4bae414aa4 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,22 @@ impl VersionFileBuilder {
.await?;
}
+ let attribution_scan = sqlx::query!(
+ "
+ INSERT INTO file_scans (file_id)
+ SELECT $1
+ WHERE EXISTS (
+ SELECT 1
+ FROM attribution_enforced_versions
+ WHERE id = $2
+ )
+ ",
+ file_id as DBFileId,
+ version_id as DBVersionId,
+ )
+ .execute(&mut *transaction)
+ .await?;
+
if let Err(err) = crate::routes::internal::delphi::run(
&mut *transaction,
DelphiRunParameters {
@@ -178,7 +221,21 @@ impl VersionFileBuilder {
)
.await
{
- tracing::error!("Error submitting new file to Delphi: {err}");
+ error!("Error submitting new file to Delphi: {err:?}");
+ }
+
+ if attribution_scan.rows_affected() > 0
+ && 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 +252,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 +295,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 +493,8 @@ impl DBVersion {
.execute(&mut *transaction)
.await?;
+ cleanup_empty_attribution_groups(transaction).await?;
+
// Sync dependencies
let project_id = sqlx::query!(
@@ -716,7 +785,7 @@ impl DBVersion {
let dependencies : DashMap> = sqlx::query!(
"
- SELECT DISTINCT dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type
+ SELECT DISTINCT d.id as dependency_id, dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type
FROM dependencies d
WHERE dependent_id = ANY($1)
",
@@ -724,10 +793,12 @@ impl DBVersion {
).fetch(&mut exec)
.try_fold(DashMap::new(), |acc : DashMap<_,Vec>, m| {
let dependency = DependencyQueryResult {
+ id: m.dependency_id,
project_id: m.dependency_project_id.map(DBProjectId),
version_id: m.dependency_version_id.map(DBVersionId),
file_name: m.file_name,
dependency_type: m.dependency_type,
+ attribution: None,
};
acc.entry(DBVersionId(m.version_id))
@@ -862,14 +933,14 @@ impl DBVersion {
})
}
- pub async fn get_files_from_hash<'a, 'b, E>(
+ pub async fn get_files_from_hash<'a, E>(
algorithm: String,
hashes: &[String],
executor: E,
redis: &RedisPool,
) -> Result, DatabaseError>
where
- E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy,
+ E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
let val = redis.get_cached_keys(
VERSION_FILES_NAMESPACE,
@@ -977,10 +1048,12 @@ pub struct VersionQueryResult {
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct DependencyQueryResult {
+ pub id: i32,
pub project_id: Option,
pub version_id: Option,
pub file_name: Option,
pub dependency_type: String,
+ pub attribution: Option,
}
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
diff --git a/apps/labrinth/src/file_hosting/mock.rs b/apps/labrinth/src/file_hosting/mock.rs
index 3e414bd393..8f18da5334 100644
--- a/apps/labrinth/src/file_hosting/mock.rs
+++ b/apps/labrinth/src/file_hosting/mock.rs
@@ -29,9 +29,7 @@ impl FileHost for MockHost {
file_publicity: FileHostPublicity,
file_bytes: Bytes,
) -> Result {
- let file_name = urlencoding::decode(file_name)
- .map_err(|_| FileHostingError::InvalidFilename)?;
- let path = get_file_path(&file_name, file_publicity);
+ let path = get_file_path(file_name, file_publicity);
std::fs::create_dir_all(
path.parent().ok_or(FileHostingError::InvalidFilename)?,
)?;
@@ -72,6 +70,16 @@ impl FileHost for MockHost {
file_name: file_name.to_string(),
})
}
+
+ async fn read_file(
+ &self,
+ file_name: &str,
+ file_publicity: FileHostPublicity,
+ ) -> Result {
+ let path = get_file_path(file_name, file_publicity);
+ let data = std::fs::read(&path)?;
+ Ok(Bytes::from(data))
+ }
}
fn get_file_path(
diff --git a/apps/labrinth/src/file_hosting/mod.rs b/apps/labrinth/src/file_hosting/mod.rs
index 667f4cb21e..29fd25d8cf 100644
--- a/apps/labrinth/src/file_hosting/mod.rs
+++ b/apps/labrinth/src/file_hosting/mod.rs
@@ -45,7 +45,11 @@ pub enum FileHostPublicity {
}
#[async_trait]
-pub trait FileHost {
+pub trait FileHost: Send + Sync {
+ /// Uploads a file at the exact storage key provided.
+ ///
+ /// Callers must URL-decode keys derived from public URLs before passing
+ /// them here, and URL-encode this key before exposing it in a public URL.
async fn upload_file(
&self,
content_type: &str,
@@ -54,17 +58,35 @@ pub trait FileHost {
file_bytes: Bytes,
) -> Result;
+ /// Returns a private URL for the exact storage key provided.
+ ///
+ /// Callers must URL-decode keys derived from public URLs before passing
+ /// them here.
async fn get_url_for_private_file(
&self,
file_name: &str,
expiry_secs: u32,
) -> Result;
+ /// Deletes the file at the exact storage key provided.
+ ///
+ /// Callers must URL-decode keys derived from public URLs before passing
+ /// them here.
async fn delete_file(
&self,
file_name: &str,
file_publicity: FileHostPublicity,
) -> Result;
+
+ /// Reads the file at the exact storage key provided.
+ ///
+ /// Callers must URL-decode keys derived from public URLs before passing
+ /// them here.
+ async fn read_file(
+ &self,
+ file_name: &str,
+ file_publicity: FileHostPublicity,
+ ) -> Result;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
diff --git a/apps/labrinth/src/file_hosting/s3_host.rs b/apps/labrinth/src/file_hosting/s3_host.rs
index 56bd0ef45c..558e4d5086 100644
--- a/apps/labrinth/src/file_hosting/s3_host.rs
+++ b/apps/labrinth/src/file_hosting/s3_host.rs
@@ -169,4 +169,28 @@ impl FileHost for S3Host {
file_name: file_name.to_string(),
})
}
+
+ async fn read_file(
+ &self,
+ file_name: &str,
+ file_publicity: FileHostPublicity,
+ ) -> Result {
+ let bucket = self.get_bucket(file_publicity);
+
+ let response = bucket
+ .client
+ .get_object()
+ .bucket(bucket.name.as_str())
+ .key(file_name)
+ .send()
+ .await
+ .map_err(|e| s3_error("reading file", e))?;
+
+ Ok(response
+ .body
+ .collect()
+ .await
+ .map_err(|e| s3_error("reading file body", e))?
+ .into_bytes())
+ }
}
diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs
index d6da0785b5..1d64722707 100644
--- a/apps/labrinth/src/lib.rs
+++ b/apps/labrinth/src/lib.rs
@@ -58,7 +58,7 @@ pub struct LabrinthConfig {
pub ro_pool: ReadOnlyPgPool,
pub redis_pool: RedisPool,
pub clickhouse: Client,
- pub file_host: Arc,
+ pub file_host: web::Data,
pub scheduler: Arc,
pub ip_salt: Pepper,
pub search_state: web::Data,
@@ -85,7 +85,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,
@@ -359,7 +359,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(web::Data::from(
labrinth_config.search_state.backend.clone(),
))
diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs
index adda6b7faf..8b636f485e 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();
@@ -178,6 +172,7 @@ async fn app() -> std::io::Result<()> {
ro_pool.into_inner(),
redis_pool,
search_backend,
+ file_host,
kafka_client,
clickhouse,
stripe_client,
diff --git a/apps/labrinth/src/models/v3/ids.rs b/apps/labrinth/src/models/v3/ids.rs
index d7919fe681..5d23815f4f 100644
--- a/apps/labrinth/src/models/v3/ids.rs
+++ b/apps/labrinth/src/models/v3/ids.rs
@@ -1,5 +1,6 @@
use ariadne::ids::base62_id;
+base62_id!(AttributionGroupId);
base62_id!(ChargeId);
base62_id!(CampaignDonationId);
base62_id!(CollectionId);
diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs
index 92abe3fddb..5c31df5b35 100644
--- a/apps/labrinth/src/models/v3/projects.rs
+++ b/apps/labrinth/src/models/v3/projects.rs
@@ -12,6 +12,7 @@ use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
+use url::Url;
use validator::Validate;
/// A project returned from the API
@@ -645,6 +646,98 @@ impl SideTypesMigrationReviewStatus {
}
}
+#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
+pub struct MissingAttributionFile {
+ pub id: FileId,
+ pub override_source: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum OverrideSource {
+ Flame {
+ id: u32,
+ title: String,
+ url: String,
+ icon_url: String,
+ },
+ Unknown,
+}
+
+#[derive(
+ Debug, Serialize, Deserialize, Clone, PartialEq, Eq, utoipa::ToSchema,
+)]
+pub struct FlameProject {
+ pub id: u32,
+ pub title: String,
+ pub url: String,
+ pub icon_url: String,
+}
+
+#[derive(
+ Debug, Serialize, Deserialize, Clone, PartialEq, Eq, utoipa::ToSchema,
+)]
+#[serde(untagged)]
+pub enum AttributionLicense {
+ Spdx(String),
+ Custom { name: String },
+}
+
+#[derive(
+ Debug, Serialize, Deserialize, Clone, PartialEq, Eq, utoipa::ToSchema,
+)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum AttributionResolutionKind {
+ License {
+ license: AttributionLicense,
+ link_to_work: Url,
+ },
+ GloballyAllowed {
+ link_to_work: Url,
+ },
+ MyProject {
+ license: AttributionLicense,
+ },
+ SpecialPermissions {
+ link_to_work: Url,
+ },
+ NoPermission {
+ link_to_work: Option,
+ },
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum AttributionModerationStatusKind {
+ NotAllowed,
+ Approved,
+ BadProof,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
+pub struct AttributionModerationStatus {
+ #[serde(flatten)]
+ pub kind: AttributionModerationStatusKind,
+ #[serde(default)]
+ pub reason: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub moderated_at: Option>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub moderated_by: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
+pub struct AttributionResolution {
+ #[serde(flatten)]
+ pub kind: AttributionResolutionKind,
+ #[serde(default)]
+ pub moderation_status: Option,
+ #[serde(default)]
+ pub updated_by_moderator: bool,
+ pub notes: String,
+ pub image_urls: Vec,
+}
+
/// A specific version of a project
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct Version {
@@ -681,6 +774,9 @@ pub struct Version {
/// A list of files available for download for this version.
pub files: Vec,
+ /// Files in this version that contain override files not yet attributed.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub files_missing_attribution: Vec,
/// A list of projects that this version depends on.
pub dependencies: Vec,
@@ -757,6 +853,7 @@ impl From for Version {
dependency_type: DependencyType::from_string(
d.dependency_type.as_str(),
),
+ attribution: d.attribution,
})
.collect(),
loaders: data.loaders.into_iter().map(Loader).collect(),
@@ -768,6 +865,7 @@ impl From for Version {
.map(|vf| (vf.field_name, vf.value.serialize_internal()))
.collect(),
components: data.components,
+ files_missing_attribution: Vec::new(),
}
}
}
@@ -899,6 +997,18 @@ pub struct Dependency {
pub file_name: Option,
/// The type of the dependency
pub dependency_type: DependencyType,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub attribution: Option,
+}
+
+#[derive(
+ Serialize, Deserialize, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
+)]
+pub struct DependencyAttribution {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub flame_project: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub resolution: Option,
}
#[derive(
diff --git a/apps/labrinth/src/queue/file_scan.rs b/apps/labrinth/src/queue/file_scan.rs
new file mode 100644
index 0000000000..d3978c4022
--- /dev/null
+++ b/apps/labrinth/src/queue/file_scan.rs
@@ -0,0 +1,1030 @@
+use std::collections::HashMap;
+use std::io::{Cursor, Read};
+
+use chrono::Utc;
+use eyre::{Result, eyre};
+use hex::ToHex;
+use sha1::Digest;
+use tokio::task::spawn_blocking;
+use tracing::{Instrument, info, info_span, warn};
+use zip::ZipArchive;
+
+use crate::database::models::ids::{
+ DBAttributionGroupId, DBProjectId, DBVersionId,
+ generate_attribution_group_id,
+};
+use crate::database::models::moderation_external_item::ExternalLicense;
+use crate::database::models::{DBFileId, DBUserId, DBVersion};
+use crate::database::{PgPool, PgTransaction, redis::RedisPool};
+use crate::env::ENV;
+use crate::file_hosting::{FileHost, FileHostPublicity};
+use crate::models::ids::FileId;
+use crate::models::projects::{
+ AttributionResolution, AttributionResolutionKind, DependencyAttribution,
+ FlameProject,
+};
+use crate::queue::moderation::{
+ ApprovalType, FingerprintResponse, FlameResponse,
+};
+use crate::util::error::Context;
+use crate::util::http::HTTP_CLIENT;
+
+/// Attribution enforcement is version/project-scoped, not file-hash-scoped.
+///
+/// Versions or projects listed in `attributions_exemptions` 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 and projects 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 attribution_enforced_versions aev on aev.id = f.version_id
+ inner join versions v on v.id = f.version_id
+ where fa.attributions_scanned_at is null
+ "#
+ )
+ .fetch_all(&mut txn)
+ .await
+ .wrap_err("fetching files to scan")?;
+
+ info!("Found {} files to scan", files_to_scan.len());
+
+ let mut scanned_ids = Vec::new();
+
+ for row in files_to_scan {
+ let human_file_id = FileId::from(row.file_id);
+ let span = info_span!("scan", file_id = %human_file_id);
+ async {
+ info!("Scanning file");
+
+ let file_id = row.file_id;
+
+ let overrides = extract_override_files_from_storage(
+ file_host, file_id, &row.url,
+ )
+ .await
+ .wrap_err_with(|| {
+ eyre!("extracting overrides for file {file_id:?}")
+ })?;
+
+ if overrides.is_empty() {
+ info!("Found no overrides");
+ } else {
+ info!("Found {} overrides", overrides.len());
+
+ let resolved = resolve_overrides(&overrides, redis, &mut txn)
+ .await
+ .wrap_err_with(|| {
+ eyre!("resolving overrides for file {file_id:?}")
+ })?;
+ info!("Resolved: {resolved:#?}");
+
+ persist_attribution_results(
+ row.project_id,
+ file_id,
+ &overrides,
+ &resolved,
+ &mut txn,
+ )
+ .await
+ .wrap_err_with(|| {
+ eyre!("persisting attribution results for file {file_id:?}")
+ })?;
+ }
+
+ scanned_ids.push(file_id.0);
+ eyre::Ok(())
+ }
+ .instrument(span)
+ .await?;
+ }
+
+ if !scanned_ids.is_empty() {
+ let now = Utc::now();
+ sqlx::query!(
+ "
+ update file_scans
+ set attributions_scanned_at = now
+ from unnest($1::bigint[], $2::timestamptz[]) as u(id, now)
+ where file_scans.file_id = u.id
+ ",
+ &scanned_ids,
+ &vec![now; scanned_ids.len()],
+ )
+ .execute(&mut txn)
+ .await
+ .wrap_err("marking files as scanned")?;
+ }
+
+ info!("Marked {} files as scanned", scanned_ids.len());
+
+ txn.commit().await.wrap_err("committing transaction")?;
+
+ Ok(())
+}
+
+pub async fn scan_file(
+ txn: &mut PgTransaction<'_>,
+ redis: &RedisPool,
+ file_host: &dyn FileHost,
+ project_id: DBProjectId,
+ file_id: DBFileId,
+ file_url: &str,
+) -> Result<()> {
+ let overrides =
+ extract_override_files_from_storage(file_host, file_id, file_url)
+ .await
+ .wrap_err_with(|| {
+ eyre!("extracting overrides for file {file_id:?}")
+ })?;
+
+ if !overrides.is_empty() {
+ let resolved = resolve_overrides(&overrides, redis, txn)
+ .await
+ .wrap_err_with(|| {
+ eyre!("resolving overrides for file {file_id:?}")
+ })?;
+
+ persist_attribution_results(
+ project_id, file_id, &overrides, &resolved, txn,
+ )
+ .await
+ .wrap_err_with(|| {
+ eyre!("persisting attribution results for file {file_id:?}")
+ })?;
+ }
+
+ sqlx::query!(
+ "
+ insert into file_scans (file_id, attributions_scanned_at)
+ values ($1, now())
+ on conflict (file_id) do update set attributions_scanned_at = now()
+ ",
+ file_id.0,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("marking file as scanned")?;
+
+ Ok(())
+}
+
+pub async fn scan_override_files(
+ file_host: &dyn FileHost,
+ file_id: DBFileId,
+ file_url: &str,
+) -> Result> {
+ extract_override_files_from_storage(file_host, file_id, file_url)
+ .await
+ .wrap_err_with(|| eyre!("extracting overrides for file {file_id:?}"))
+}
+
+async fn extract_override_files_from_storage(
+ file_host: &dyn FileHost,
+ file_id: DBFileId,
+ file_url: &str,
+) -> Result> {
+ let key = file_url
+ .strip_prefix(&ENV.CDN_URL)
+ .unwrap_or(file_url)
+ .trim_start_matches('/');
+ let key = urlencoding::decode(key).wrap_err("decoding file URL path")?;
+
+ let file_data = file_host
+ .read_file(&key, FileHostPublicity::Public)
+ .await
+ .wrap_err_with(|| {
+ eyre!("reading file {file_id:?} from storage at {key}")
+ })?;
+
+ spawn_blocking(move || extract_override_files(&file_data))
+ .await
+ .wrap_err("extracting override files")?
+ .wrap_err("extracting override files")
+}
+
+#[derive(Debug)]
+pub struct OverrideFile {
+ pub path: String,
+ pub sha1: String,
+ pub murmur2: u32,
+}
+
+#[derive(Debug)]
+pub enum OverrideResolution {
+ OnModrinth,
+ ExternalLicense {
+ id: i64,
+ status: ApprovalType,
+ link: Option,
+ flame_project: Option,
+ },
+ Flame(FlameProject),
+ Unknown,
+}
+
+const OVERRIDE_PREFIXES: &[&str] = &[
+ "overrides/mods",
+ "client-overrides/mods",
+ "server-overrides/mods",
+ "overrides/shaderpacks",
+ "client-overrides/shaderpacks",
+ "overrides/resourcepacks",
+ "client-overrides/resourcepacks",
+];
+
+fn extract_override_files(data: &[u8]) -> Result> {
+ let reader = Cursor::new(data);
+ let mut zip =
+ ZipArchive::new(reader).wrap_err("creating zip archive reader")?;
+
+ let mut files = Vec::new();
+
+ for i in 0..zip.len() {
+ let mut file = zip
+ .by_index(i)
+ .wrap_err_with(|| eyre!("reading file {i}"))?;
+ let name = file.name().to_string();
+
+ if file.is_dir() {
+ continue;
+ }
+
+ if !OVERRIDE_PREFIXES
+ .iter()
+ .any(|prefix| name.starts_with(prefix))
+ {
+ continue;
+ }
+
+ let should_scan_file = name.contains(".jar")
+ || (name.contains(".zip") && !name.ends_with(".zip.txt"));
+
+ if name.matches('/').count() > 2 || !should_scan_file {
+ 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!(
+ r#"
+ insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)
+ values ($1, $2, $3, $4)
+ on conflict (group_id, sha1) do update
+ set moderation_external_license_id = excluded.moderation_external_license_id
+ "#,
+ *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!(
+ r#"
+ insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)
+ values ($1, $2, $3, $4)
+ on conflict (group_id, sha1) do update
+ set moderation_external_license_id = excluded.moderation_external_license_id
+ "#,
+ 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!(
+ r#"
+ insert into project_attribution_files (group_id, name, sha1)
+ select $1, unnest($2::text[]), unnest($3::bytea[])
+ on conflict (group_id, sha1) do nothing
+ "#,
+ 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!(
+ r#"
+ insert into project_attribution_files (group_id, name, sha1)
+ values ($1, $2, $3)
+ on conflict (group_id, sha1) do nothing
+ "#,
+ group_id as DBAttributionGroupId,
+ &file.path,
+ &file.sha1.as_bytes().to_vec() as &[u8],
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting unknown attribution file")?;
+ }
+
+ if !all_sha1s.is_empty() {
+ sqlx::query!(
+ "
+ insert into override_file_sources (sha1, file_id)
+ select unnest($1::bytea[]), $2
+ on conflict do nothing
+ ",
+ &all_sha1s,
+ file_id as DBFileId,
+ )
+ .execute(&mut *txn)
+ .await
+ .wrap_err("inserting override file sources")?;
+ }
+
+ Ok(())
+}
+
+fn default_external_license_attribution(
+ status: ApprovalType,
+ link: Option,
+) -> Option {
+ match status {
+ ApprovalType::Yes
+ | ApprovalType::WithAttributionAndSource
+ | ApprovalType::WithAttribution => link
+ .and_then(|link| url::Url::parse(&link).ok())
+ .and_then(|link_to_work| {
+ serde_json::to_value(AttributionResolution {
+ kind: AttributionResolutionKind::GloballyAllowed {
+ link_to_work,
+ },
+ moderation_status: None,
+ updated_by_moderator: false,
+ notes: String::new(),
+ image_urls: Vec::new(),
+ })
+ .ok()
+ }),
+ ApprovalType::No => {
+ let link_to_work =
+ link.and_then(|link| url::Url::parse(&link).ok());
+
+ serde_json::to_value(AttributionResolution {
+ kind: AttributionResolutionKind::NoPermission { link_to_work },
+ moderation_status: None,
+ updated_by_moderator: false,
+ notes: String::new(),
+ image_urls: Vec::new(),
+ })
+ .ok()
+ }
+ ApprovalType::PermanentNo | ApprovalType::Unidentified => None,
+ }
+}
+
+async fn resolve_overrides(
+ overrides: &[OverrideFile],
+ redis: &RedisPool,
+ txn: &mut PgTransaction<'_>,
+) -> Result> {
+ let mut results: HashMap = HashMap::new();
+ let mut remaining: Vec = (0..overrides.len()).collect();
+
+ if overrides.is_empty() {
+ return Ok(results);
+ }
+
+ let hashes: Vec =
+ overrides.iter().map(|x| x.sha1.clone()).collect();
+ let files = DBVersion::get_files_from_hash(
+ "sha1".to_string(),
+ &hashes,
+ &mut *txn,
+ redis,
+ )
+ .await
+ .wrap_err("fetching files on platform by hash")?;
+
+ let version_ids: Vec<_> = files.iter().map(|x| x.version_id).collect();
+ let versions_data = DBVersion::get_many(&version_ids, &mut *txn, redis)
+ .await
+ .wrap_err("fetching versions")?;
+
+ for file in &files {
+ if !versions_data.iter().any(|v| v.inner.id == file.version_id) {
+ continue;
+ }
+
+ if let Some(hash) = file.hashes.get("sha1")
+ && let Some(pos) =
+ remaining.iter().position(|i| overrides[*i].sha1 == *hash)
+ {
+ let idx = remaining.remove(pos);
+ results.insert(
+ overrides[idx].sha1.clone(),
+ OverrideResolution::OnModrinth,
+ );
+ }
+ }
+
+ if remaining.is_empty() {
+ return Ok(results);
+ }
+
+ let rows = sqlx::query!(
+ "
+ SELECT encode(mef.sha1, 'escape') sha1, mel.id, mel.status status, mel.link
+ FROM moderation_external_files mef
+ INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id
+ WHERE mef.sha1 = ANY($1)
+ ",
+ &remaining
+ .iter()
+ .map(|i| overrides[*i].sha1.as_bytes().to_vec())
+ .collect::>()
+ )
+ .fetch_all(&mut *txn)
+ .await
+ .wrap_err("fetching external file licenses")?;
+
+ let mut direct_external_licenses = HashMap::new();
+ for row in rows {
+ if let Some(sha1) = row.sha1 {
+ direct_external_licenses.insert(
+ sha1,
+ (
+ row.id,
+ ApprovalType::from_string(&row.status)
+ .unwrap_or(ApprovalType::Unidentified),
+ row.link,
+ ),
+ );
+ }
+ }
+
+ let fingerprints: Vec =
+ remaining.iter().map(|i| overrides[*i].murmur2).collect();
+ let res = HTTP_CLIENT
+ .post(format!("{}/v1/fingerprints", ENV.FLAME_ANVIL_URL))
+ .json(&serde_json::json!({
+ "fingerprints": fingerprints
+ }))
+ .send()
+ .await;
+
+ if let Err(e) = &res {
+ warn!("Flame fingerprint request failed: {e}");
+ }
+
+ if let Ok(res) = res {
+ let body = res
+ .text()
+ .await
+ .wrap_err("reading Flame fingerprint response")?;
+
+ let flame_files: Vec<_> =
+ serde_json::from_str::>(&body)
+ .ok()
+ .map(|x| {
+ x.data
+ .exact_matches
+ .into_iter()
+ .map(|m| m.file)
+ .collect::>()
+ })
+ .unwrap_or_default();
+
+ let mut flame_matches: Vec<(String, u32)> = Vec::new();
+ for flame_file in &flame_files {
+ if let Some(hash) = flame_file
+ .hashes
+ .iter()
+ .find(|x| x.algo == 1)
+ .map(|x| x.value.clone())
+ {
+ flame_matches.push((hash, flame_file.mod_id));
+ }
+ }
+
+ let project_license_rows = sqlx::query!(
+ "
+ SELECT mel.id, mel.flame_project_id, mel.status status, mel.link
+ FROM moderation_external_licenses mel
+ WHERE mel.flame_project_id = ANY($1)
+ ",
+ &flame_matches.iter().map(|x| x.1 as i32).collect::>()
+ )
+ .fetch_all(&mut *txn)
+ .await
+ .wrap_err("fetching Flame project licenses")?;
+
+ let mut project_external_licenses = HashMap::new();
+ for row in project_license_rows {
+ if let Some(flame_project_id) = row.flame_project_id {
+ project_external_licenses.insert(
+ flame_project_id as u32,
+ (
+ row.id,
+ ApprovalType::from_string(&row.status)
+ .unwrap_or(ApprovalType::Unidentified),
+ row.link,
+ ),
+ );
+ }
+ }
+
+ let flame_projects_res = HTTP_CLIENT
+ .post(format!("{}/v1/mods", ENV.FLAME_ANVIL_URL))
+ .json(&serde_json::json!({
+ "modIds": flame_matches.iter().map(|x| x.1).collect::>()
+ }))
+ .send()
+ .await;
+
+ let flame_projects = match flame_projects_res {
+ Ok(res) => res
+ .text()
+ .await
+ .ok()
+ .and_then(|t| {
+ serde_json::from_str::<
+ FlameResponse<
+ Vec,
+ >,
+ >(&t)
+ .ok()
+ })
+ .map(|x| x.data)
+ .unwrap_or_default(),
+ Err(e) => {
+ warn!("Flame projects request failed: {e}");
+ Vec::new()
+ }
+ };
+ let mut flame_project_metadata = HashMap::new();
+ for project in flame_projects {
+ if flame_project_url_is_not_found(&project.links.website_url).await
+ {
+ info!(
+ "Flame project {} at {:?} returned 404, ignoring",
+ project.id, project.links.website_url,
+ );
+ continue;
+ }
+
+ flame_project_metadata.insert(
+ project.id,
+ FlameProject {
+ id: project.id,
+ title: project.name,
+ url: project.links.website_url,
+ icon_url: project.logo.thumbnail_url,
+ },
+ );
+ }
+
+ 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 flame_project =
+ flame_project_metadata.get(flame_project_id).cloned();
+
+ 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,
+ },
+ );
+ } 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,
+ },
+ );
+
+ insert_hashes.push(overrides[idx].sha1.as_bytes().to_vec());
+ insert_filenames.push(Some(overrides[idx].path.clone()));
+ insert_ids.push(*id);
+ } else if let Some(flame_project) = flame_project {
+ 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)
+}
+
+async fn flame_project_url_is_not_found(url: &str) -> bool {
+ match HTTP_CLIENT.get(url).send().await {
+ Ok(response) => response.status() == reqwest::StatusCode::NOT_FOUND,
+ Err(err) => {
+ warn!("Flame project URL check failed for {url}: {err}");
+ false
+ }
+ }
+}
+
+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 versions v on v.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.project_id = v.mod_id
+ and (
+ pag.attribution is null
+ or pag.attribution->>'kind' = 'no_permission'
+ or coalesce(
+ pag.attribution->'moderation_status'->>'kind',
+ 'approved'
+ ) != 'approved'
+ )
+ "#,
+ &version_ids.iter().map(|v| v.0).collect::>(),
+ )
+ .fetch_all(exec)
+ .await
+ .wrap_err("fetching files missing attribution")?;
+
+ let mut result = std::collections::HashMap::new();
+ for row in rows {
+ let flame_project = row
+ .flame_project
+ .and_then(|v| serde_json::from_value(v).ok());
+ result
+ .entry(row.version_id)
+ .or_insert_with(Vec::new)
+ .push((row.file_id, flame_project));
+ }
+
+ Ok(result)
+}
+
+pub struct DependencyAttributionData {
+ pub attribution: DependencyAttribution,
+}
+
+pub async fn get_dependency_attributions<'a, E>(
+ exec: E,
+ version_ids: &[DBVersionId],
+) -> Result>
+where
+ E: sqlx::Executor<'a, Database = sqlx::Postgres>,
+{
+ if version_ids.is_empty() {
+ return Ok(HashMap::new());
+ }
+
+ let version_ids_vec: Vec<_> = version_ids.iter().map(|v| v.0).collect();
+
+ let rows = sqlx::query!(
+ r#"
+ select
+ d.id as "dependency_id!",
+ pag.attribution,
+ pag.flame_project,
+ pag.project_id as "project_id: DBProjectId"
+ from dependencies d
+ inner join files f on f.version_id = d.dependent_id
+ inner join attribution_enforced_versions aev on aev.id = f.version_id
+ inner join versions v on v.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 pag.project_id = v.mod_id
+ and d.dependency_file_name is not null
+ and (
+ pag.flame_project is not null
+ or pag.attribution is not null
+ )
+ and split_part(paf.name, '/', -1) = d.dependency_file_name
+ "#,
+ &version_ids_vec,
+ )
+ .fetch_all(exec)
+ .await
+ .wrap_err("fetching dependency attributions")?;
+
+ let mut result = HashMap::new();
+ for row in rows {
+ let attribution: Option =
+ row.attribution.and_then(|v| serde_json::from_value(v).ok());
+
+ let flame_project: Option = row
+ .flame_project
+ .and_then(|v| serde_json::from_value(v).ok());
+
+ let resolution = attribution.map(|a| a.kind);
+
+ result.insert(
+ row.dependency_id,
+ DependencyAttributionData {
+ attribution: DependencyAttribution {
+ flame_project,
+ resolution,
+ },
+ },
+ );
+ }
+
+ Ok(result)
+}
diff --git a/apps/labrinth/src/queue/mod.rs b/apps/labrinth/src/queue/mod.rs
index 666670dc0b..0d7dcb273b 100644
--- a/apps/labrinth/src/queue/mod.rs
+++ b/apps/labrinth/src/queue/mod.rs
@@ -1,6 +1,7 @@
pub mod analytics;
pub mod billing;
pub mod email;
+pub mod file_scan;
pub mod moderation;
pub mod payouts;
pub mod server_ping;
diff --git a/apps/labrinth/src/queue/moderation.rs b/apps/labrinth/src/queue/moderation.rs
index 7d852f4eb3..48f55a6c06 100644
--- a/apps/labrinth/src/queue/moderation.rs
+++ b/apps/labrinth/src/queue/moderation.rs
@@ -570,7 +570,7 @@ impl AutomatedModerationQueue {
Vec::new()
} else {
let res = client
- .post(format!("{}v1/mods", ENV.FLAME_ANVIL_URL))
+ .post(format!("{}/v1/mods", ENV.FLAME_ANVIL_URL))
.json(&serde_json::json!({
"modIds": flame_files.iter().map(|x| x.1).collect::>()
}))
@@ -579,7 +579,7 @@ impl AutomatedModerationQueue {
.text()
.await?;
- serde_json::from_str::>>(&res)?.data
+ serde_json::from_str::>>(&res)?.data
};
let mut missing_metadata = MissingMetadata {
@@ -823,7 +823,7 @@ pub enum ApprovalType {
}
impl ApprovalType {
- fn approved(&self) -> bool {
+ pub fn approved(&self) -> bool {
match self {
ApprovalType::Yes => true,
ApprovalType::WithAttributionAndSource => true,
@@ -896,11 +896,18 @@ pub struct FlameFileHash {
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
-pub struct FlameProject {
+pub struct FlameProjectResponse {
pub id: u32,
pub name: String,
pub slug: String,
pub links: FlameLinks,
+ pub logo: FlameLogo,
+}
+
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FlameLogo {
+ pub thumbnail_url: String,
}
#[derive(Deserialize, Serialize)]
diff --git a/apps/labrinth/src/routes/internal/attribution.rs b/apps/labrinth/src/routes/internal/attribution.rs
new file mode 100644
index 0000000000..38555da299
--- /dev/null
+++ b/apps/labrinth/src/routes/internal/attribution.rs
@@ -0,0 +1,863 @@
+use actix_web::{HttpRequest, get, patch, post, web};
+use chrono::{DateTime, Utc};
+use eyre::eyre;
+use serde::{Deserialize, Serialize};
+
+use crate::auth::get_user_from_headers;
+use crate::database::PgPool;
+use crate::database::models::{
+ DBOrganization, DBTeamMember,
+ ids::{
+ DBAttributionGroupId, DBProjectId, DBVersionId,
+ generate_attribution_group_id,
+ },
+};
+use crate::database::redis::RedisPool;
+use crate::models::ids::{ProjectId, VersionId};
+use crate::models::pats::Scopes;
+use crate::models::projects::{
+ AttributionModerationStatusKind, AttributionResolution,
+ AttributionResolutionKind, FlameProject,
+};
+use crate::models::teams::ProjectPermissions;
+use crate::models::users::User;
+use crate::queue::moderation::ApprovalType;
+use crate::queue::session::AuthQueue;
+use crate::routes::ApiError;
+use crate::util::error::Context;
+
+pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
+ cfg.service(list)
+ .service(update_group)
+ .service(scan)
+ .service(assign)
+ .service(split);
+}
+
+#[derive(Serialize)]
+struct AttributionGroupResponse {
+ id: crate::models::ids::AttributionGroupId,
+ flame_project: Option,
+ attribution: Option,
+ attributed_at: Option>,
+ attributed_by: Option,
+ files: Vec,
+ versions: Vec,
+}
+
+#[derive(Clone, Serialize)]
+struct VersionInfo {
+ id: VersionId,
+ name: String,
+ version_number: String,
+ date_created: chrono::DateTime,
+}
+
+#[derive(Serialize)]
+struct AttributionFileResponse {
+ name: String,
+ sha1: String,
+ versions: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ moderation_external_license_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ moderation_external_license: Option,
+}
+
+#[derive(Clone, Serialize)]
+struct ModerationExternalLicenseResponse {
+ id: i64,
+ title: Option,
+ status: ApprovalType,
+ link: Option,
+ exceptions: Option,
+ proof: Option,
+ flame_project_id: Option,
+ inserted_at: Option>,
+ inserted_by: Option,
+ updated_at: Option>,
+ updated_by: Option,
+}
+
+#[derive(Deserialize, utoipa::ToSchema)]
+struct ScanBody {
+ version_ids: Vec,
+}
+
+#[derive(Serialize, utoipa::ToSchema)]
+struct ScanResponse {
+ queued_files: u64,
+}
+
+#[utoipa::path]
+#[post("/scan")]
+async fn scan(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ web::Json(body): web::Json,
+) -> Result, ApiError> {
+ let user = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::VERSION_WRITE,
+ )
+ .await?
+ .1;
+
+ let mut version_ids: Vec = body
+ .version_ids
+ .into_iter()
+ .map(|id| DBVersionId::from(id).0)
+ .collect();
+ version_ids.sort_unstable();
+ version_ids.dedup();
+
+ if version_ids.is_empty() {
+ return Ok(web::Json(ScanResponse { queued_files: 0 }));
+ }
+
+ let versions = sqlx::query!(
+ r#"
+ select
+ id as "id: DBVersionId",
+ mod_id as "project_id: DBProjectId"
+ from versions
+ where id = any($1)
+ "#,
+ &version_ids,
+ )
+ .fetch_all(pool.as_ref())
+ .await
+ .wrap_internal_err("failed to fetch versions for attribution scan")?;
+
+ if versions.len() != version_ids.len() {
+ return Err(ApiError::NotFound);
+ }
+
+ let mut project_ids: Vec =
+ versions.iter().map(|version| version.project_id).collect();
+ project_ids.sort_unstable_by_key(|id| id.0);
+ project_ids.dedup_by_key(|id| id.0);
+
+ for project_id in project_ids {
+ ensure_can_upload_versions_to_project(
+ pool.as_ref(),
+ project_id,
+ &user,
+ "you do not have permission to upload versions to this project",
+ )
+ .await?;
+ }
+
+ let result = sqlx::query!(
+ r#"
+ insert into file_scans (file_id)
+ select f.id
+ from files f
+ inner join attribution_enforced_versions aev on aev.id = f.version_id
+ where f.version_id = any($1)
+ on conflict (file_id) do nothing
+ "#,
+ &version_ids,
+ )
+ .execute(pool.as_ref())
+ .await
+ .wrap_internal_err("failed to queue version files for attribution scan")?;
+
+ Ok(web::Json(ScanResponse {
+ queued_files: result.rows_affected(),
+ }))
+}
+
+#[utoipa::path]
+#[get("/{project_id}")]
+async fn list(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ path: web::Path,
+) -> Result>, ApiError> {
+ let project_id: DBProjectId = path.into_inner().into();
+ let requester_is_mod = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_READ,
+ )
+ .await
+ .ok()
+ .is_some_and(|(_, user)| user.role.is_mod());
+
+ let groups = sqlx::query!(
+ r#"
+ select
+ g.id as "id: DBAttributionGroupId",
+ g.flame_project,
+ g.attribution,
+ g.attributed_at,
+ g.attributed_by as "attributed_by: i64"
+ from project_attribution_groups g
+ where g.project_id = $1
+ "#,
+ project_id as DBProjectId,
+ )
+ .fetch_all(pool.as_ref())
+ .await
+ .wrap_internal_err("failed to fetch attribution groups")?;
+
+ let group_ids: Vec = groups.iter().map(|g| g.id.0).collect();
+
+ let files = if group_ids.is_empty() {
+ Vec::new()
+ } else {
+ sqlx::query!(
+ r#"
+ select
+ paf.group_id as "group_id!",
+ paf.name as "name!",
+ convert_from(paf.sha1, 'UTF8') as "sha1!",
+ paf.moderation_external_license_id,
+ coalesce(array_agg(distinct aev.id) filter (where aev.id is not null), '{}') as "version_ids!: Vec"
+ from project_attribution_files paf
+ left join override_file_sources ofs on ofs.sha1 = paf.sha1
+ left join files f on f.id = ofs.file_id
+ left join versions v on v.id = f.version_id and v.mod_id = $2
+ left join attribution_enforced_versions aev on aev.id = v.id
+ where paf.group_id = ANY($1)
+ group by paf.group_id, paf.name, paf.sha1, paf.moderation_external_license_id
+ "#,
+ &group_ids,
+ project_id as DBProjectId,
+ )
+ .fetch_all(pool.as_ref())
+ .await
+ .wrap_internal_err("failed to fetch attribution group files")?
+ };
+
+ let moderation_external_licenses = if requester_is_mod {
+ let mut ids: Vec = files
+ .iter()
+ .filter_map(|f| f.moderation_external_license_id)
+ .collect();
+ ids.sort_unstable();
+ ids.dedup();
+
+ if ids.is_empty() {
+ std::collections::HashMap::new()
+ } else {
+ sqlx::query!(
+ r#"
+ select
+ id,
+ title,
+ status,
+ link,
+ exceptions,
+ proof,
+ flame_project_id,
+ inserted_at,
+ inserted_by,
+ updated_at,
+ updated_by
+ from moderation_external_licenses
+ where id = ANY($1)
+ "#,
+ &ids,
+ )
+ .fetch_all(pool.as_ref())
+ .await
+ .wrap_internal_err("failed to fetch moderation external licenses")?
+ .into_iter()
+ .map(|row| {
+ (
+ row.id,
+ ModerationExternalLicenseResponse {
+ id: row.id,
+ title: row.title,
+ status: ApprovalType::from_string(&row.status)
+ .unwrap_or(ApprovalType::Unidentified),
+ link: row.link,
+ exceptions: row.exceptions,
+ proof: row.proof,
+ flame_project_id: row.flame_project_id,
+ inserted_at: row.inserted_at,
+ inserted_by: row.inserted_by,
+ updated_at: row.updated_at,
+ updated_by: row.updated_by,
+ },
+ )
+ })
+ .collect()
+ }
+ } else {
+ std::collections::HashMap::new()
+ };
+
+ let mut all_version_ids: Vec = files
+ .iter()
+ .flat_map(|f| f.version_ids.iter().copied())
+ .collect();
+ all_version_ids.sort_unstable();
+ all_version_ids.dedup();
+
+ let version_infos = if all_version_ids.is_empty() {
+ Vec::new()
+ } else {
+ let rows = sqlx::query!(
+ "
+ select id, name, version_number, date_published
+ from versions
+ where id = ANY($1)
+ order by date_published desc
+ ",
+ &all_version_ids,
+ )
+ .fetch_all(pool.as_ref())
+ .await
+ .wrap_internal_err("failed to fetch attribution group versions")?;
+ rows.into_iter()
+ .map(|v| VersionInfo {
+ id: VersionId(v.id as u64),
+ name: v.name,
+ version_number: v.version_number,
+ date_created: v.date_published,
+ })
+ .collect()
+ };
+ let version_order = version_infos
+ .iter()
+ .enumerate()
+ .map(|(index, version)| (version.id, index))
+ .collect::>();
+
+ let mut result = Vec::new();
+ for group in groups {
+ let group_files: Vec = files
+ .iter()
+ .filter(|f| f.group_id == group.id.0)
+ .map(|f| AttributionFileResponse {
+ name: f.name.clone(),
+ sha1: f.sha1.clone(),
+ moderation_external_license_id: if requester_is_mod {
+ f.moderation_external_license_id
+ } else {
+ None
+ },
+ moderation_external_license: if requester_is_mod {
+ f.moderation_external_license_id.and_then(|id| {
+ moderation_external_licenses.get(&id).cloned()
+ })
+ } else {
+ None
+ },
+ versions: {
+ let mut versions: Vec<_> = f
+ .version_ids
+ .iter()
+ .copied()
+ .map(|id| VersionId(id as u64))
+ .collect();
+ versions.sort_by_key(|id| {
+ version_order.get(id).copied().unwrap_or(usize::MAX)
+ });
+ versions
+ },
+ })
+ .collect();
+ let group_version_ids = group_files
+ .iter()
+ .flat_map(|file| file.versions.iter().copied())
+ .collect::>();
+ let group_versions = version_infos
+ .iter()
+ .filter(|version| group_version_ids.contains(&version.id))
+ .cloned()
+ .collect();
+
+ let mut attribution = group.attribution.and_then(|v| {
+ serde_json::from_value::(v).ok()
+ });
+ if let Some(moderation_status) = attribution
+ .as_mut()
+ .and_then(|a| a.moderation_status.as_mut())
+ && !requester_is_mod
+ {
+ moderation_status.moderated_by = None;
+ }
+ let attributed_by = if attribution
+ .as_ref()
+ .is_some_and(|attribution| attribution.updated_by_moderator)
+ && !requester_is_mod
+ {
+ None
+ } else {
+ group
+ .attributed_by
+ .map(|id| ariadne::ids::UserId(id as u64))
+ };
+
+ result.push(AttributionGroupResponse {
+ id: group.id.into(),
+ flame_project: group
+ .flame_project
+ .and_then(|v| serde_json::from_value(v).ok()),
+ attribution,
+ attributed_at: group.attributed_at,
+ attributed_by,
+ files: group_files,
+ versions: group_versions,
+ });
+ }
+
+ Ok(web::Json(result))
+}
+
+#[derive(Deserialize, utoipa::ToSchema)]
+struct UpdateGroupBody {
+ attribution: AttributionResolution,
+}
+
+#[utoipa::path]
+#[patch("/group/{group_id}")]
+async fn update_group(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ path: web::Path,
+ web::Json(body): web::Json,
+) -> Result<(), ApiError> {
+ let group_id = path.into_inner();
+ let user = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::VERSION_WRITE,
+ )
+ .await?
+ .1;
+
+ if !can_edit_attribution_group(pool.as_ref(), group_id, &user).await? {
+ return Err(ApiError::CustomAuthentication(
+ "This attribution group cannot be edited".to_string(),
+ ));
+ }
+
+ if matches!(
+ body.attribution.kind,
+ AttributionResolutionKind::GloballyAllowed { .. }
+ ) && !user.role.is_mod()
+ {
+ return Err(ApiError::CustomAuthentication(
+ "Only moderators can set globally allowed attributions".to_string(),
+ ));
+ }
+
+ if body.attribution.moderation_status.is_some() && !user.role.is_mod() {
+ return Err(ApiError::CustomAuthentication(
+ "Only moderators can set attribution moderation status".to_string(),
+ ));
+ }
+
+ let mut attribution = body.attribution;
+ attribution.updated_by_moderator = user.role.is_mod();
+ if let Some(moderation_status) = &mut attribution.moderation_status {
+ moderation_status.moderated_at = Some(Utc::now());
+ moderation_status.moderated_by = Some(user.id);
+ }
+
+ let result = sqlx::query!(
+ "
+ update project_attribution_groups
+ set attribution = $1, attributed_at = now(), attributed_by = $3
+ where id = $2
+ ",
+ &serde_json::to_value(&attribution).unwrap_or_default(),
+ group_id,
+ user.id.0 as i64,
+ )
+ .execute(pool.as_ref())
+ .await
+ .wrap_internal_err("failed to update attribution group")?;
+
+ if result.rows_affected() == 0 {
+ return Err(ApiError::NotFound);
+ }
+
+ Ok(())
+}
+
+#[derive(Deserialize, utoipa::ToSchema)]
+struct AssignBody {
+ sha1: String,
+ target_group_id: i64,
+ project_id: ProjectId,
+}
+
+#[utoipa::path]
+#[post("/assign")]
+async fn assign(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ web::Json(body): web::Json,
+) -> Result<(), ApiError> {
+ let user = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::VERSION_WRITE,
+ )
+ .await?
+ .1;
+
+ let sha1 = body.sha1.trim().to_lowercase();
+ if hex_to_bytes(&sha1).is_none() {
+ return Err(ApiError::InvalidInput(
+ "invalid sha1 hex string".to_string(),
+ ));
+ }
+ let sha1_bytes = sha1.as_bytes().to_vec();
+ let project_id: DBProjectId = body.project_id.into();
+
+ let source_group_id = sqlx::query_scalar!(
+ "
+ select paf.group_id
+ from project_attribution_files paf
+ inner join project_attribution_groups pag on pag.id = paf.group_id
+ where paf.sha1 = $1 and pag.project_id = $2
+ ",
+ &sha1_bytes,
+ project_id as DBProjectId,
+ )
+ .fetch_optional(pool.as_ref())
+ .await
+ .wrap_internal_err("failed to fetch source attribution group")?
+ .ok_or(ApiError::NotFound)?;
+
+ let target_group_exists = sqlx::query_scalar!(
+ "
+ select exists(
+ select 1 from project_attribution_groups where id = $1 and project_id = $2
+ ) as \"exists!\"
+ ",
+ body.target_group_id,
+ project_id as DBProjectId,
+ )
+ .fetch_one(pool.as_ref())
+ .await
+ .wrap_internal_err("failed to check target attribution group")?;
+
+ if !target_group_exists {
+ return Err(ApiError::NotFound);
+ }
+
+ if !can_edit_attribution_group(pool.as_ref(), source_group_id, &user)
+ .await?
+ || !can_edit_attribution_group(
+ pool.as_ref(),
+ body.target_group_id,
+ &user,
+ )
+ .await?
+ {
+ return Err(ApiError::CustomAuthentication(
+ "This attribution group cannot be edited".to_string(),
+ ));
+ }
+
+ let mut txn = pool.begin().await.wrap_internal_err(
+ "failed to begin attribution assignment transaction",
+ )?;
+
+ let result = sqlx::query!(
+ r#"
+ insert into project_attribution_files (
+ group_id,
+ name,
+ sha1,
+ moderation_external_license_id
+ )
+ select
+ $1,
+ paf.name,
+ paf.sha1,
+ paf.moderation_external_license_id
+ from project_attribution_files paf
+ inner join project_attribution_groups pag on pag.id = paf.group_id
+ where paf.sha1 = $2 and pag.project_id = $3
+ order by paf.moderation_external_license_id nulls last, paf.name
+ limit 1
+ on conflict (group_id, sha1) do update
+ set moderation_external_license_id = coalesce(
+ project_attribution_files.moderation_external_license_id,
+ excluded.moderation_external_license_id
+ )
+ "#,
+ body.target_group_id,
+ &sha1_bytes,
+ project_id as DBProjectId,
+ )
+ .execute(&mut txn)
+ .await
+ .wrap_internal_err("failed to insert assigned attribution file")?;
+
+ if result.rows_affected() == 0 {
+ return Err(ApiError::NotFound);
+ }
+
+ sqlx::query!(
+ r#"
+ delete from project_attribution_files paf
+ using project_attribution_groups pag
+ where pag.id = paf.group_id
+ and pag.project_id = $1
+ and paf.sha1 = $2
+ and paf.group_id != $3
+ "#,
+ project_id as DBProjectId,
+ &sha1_bytes,
+ body.target_group_id,
+ )
+ .execute(&mut txn)
+ .await
+ .wrap_internal_err("failed to remove old assigned attribution files")?;
+
+ txn.commit().await.wrap_internal_err(
+ "failed to commit attribution assignment transaction",
+ )?;
+
+ cleanup_empty_groups(pool.as_ref())
+ .await
+ .wrap_internal_err("failed to clean up empty attribution groups")?;
+
+ Ok(())
+}
+
+#[derive(Deserialize, utoipa::ToSchema)]
+struct SplitBody {
+ sha1: String,
+ project_id: ProjectId,
+}
+
+#[utoipa::path]
+#[post("/split")]
+async fn split(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ web::Json(body): web::Json,
+) -> Result<(), ApiError> {
+ let user = get_user_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::VERSION_WRITE,
+ )
+ .await?
+ .1;
+
+ let sha1 = body.sha1.trim().to_lowercase();
+ if hex_to_bytes(&sha1).is_none() {
+ return Err(ApiError::InvalidInput(
+ "invalid sha1 hex string".to_string(),
+ ));
+ }
+ let sha1_bytes = sha1.as_bytes().to_vec();
+ let project_id: DBProjectId = body.project_id.into();
+
+ let existing = sqlx::query!(
+ "
+ select paf.group_id, paf.name from project_attribution_files paf
+ inner join project_attribution_groups pag on pag.id = paf.group_id
+ where paf.sha1 = $1 and pag.project_id = $2
+ ",
+ &sha1_bytes,
+ project_id as DBProjectId,
+ )
+ .fetch_optional(pool.as_ref())
+ .await
+ .wrap_internal_err("failed to fetch attribution file to split")?;
+
+ let Some(existing) = existing else {
+ return Err(ApiError::NotFound);
+ };
+
+ if !can_edit_attribution_group(pool.as_ref(), existing.group_id, &user)
+ .await?
+ {
+ return Err(ApiError::CustomAuthentication(
+ "This attribution group cannot be edited".to_string(),
+ ));
+ }
+
+ let mut txn = pool
+ .begin()
+ .await
+ .wrap_internal_err("failed to begin attribution split transaction")?;
+
+ let new_group_id = generate_attribution_group_id(&mut txn)
+ .await
+ .wrap_internal_err("failed to generate attribution group id")?;
+
+ sqlx::query!(
+ "
+ insert into project_attribution_groups (id, project_id)
+ values ($1, $2)
+ ",
+ new_group_id as DBAttributionGroupId,
+ project_id as DBProjectId,
+ )
+ .execute(&mut txn)
+ .await
+ .wrap_internal_err("failed to insert split attribution group")?;
+
+ sqlx::query!(
+ "
+ update project_attribution_files
+ set group_id = $1
+ where sha1 = $2 and group_id = $3
+ ",
+ new_group_id as DBAttributionGroupId,
+ &sha1_bytes,
+ existing.group_id,
+ )
+ .execute(&mut txn)
+ .await
+ .wrap_internal_err("failed to move attribution file to split group")?;
+
+ txn.commit()
+ .await
+ .wrap_internal_err("failed to commit attribution split transaction")?;
+
+ cleanup_empty_groups(pool.as_ref())
+ .await
+ .wrap_internal_err("failed to clean up empty attribution groups")?;
+
+ Ok(())
+}
+
+async fn can_edit_attribution_group(
+ pool: &PgPool,
+ group_id: i64,
+ user: &User,
+) -> Result {
+ let group = sqlx::query!(
+ r#"
+ select attribution, project_id as "project_id: DBProjectId"
+ from project_attribution_groups
+ where id = $1
+ "#,
+ group_id,
+ )
+ .fetch_optional(pool)
+ .await
+ .wrap_internal_err("failed to fetch attribution group")?
+ .ok_or(ApiError::NotFound)?;
+
+ ensure_can_upload_versions_to_project(
+ pool,
+ group.project_id,
+ user,
+ "you do not have permission to edit this attribution group",
+ )
+ .await?;
+
+ let attribution: Option = group
+ .attribution
+ .and_then(|value| serde_json::from_value(value).ok());
+
+ Ok(!matches!(
+ attribution
+ .and_then(|attribution| attribution.moderation_status)
+ .map(|status| status.kind),
+ Some(AttributionModerationStatusKind::NotAllowed)
+ ))
+}
+
+async fn ensure_can_upload_versions_to_project(
+ pool: &PgPool,
+ project_id: DBProjectId,
+ user: &User,
+ permission_error: &'static str,
+) -> Result<(), ApiError> {
+ if user.role.is_mod() {
+ return Ok(());
+ }
+
+ let team_member = DBTeamMember::get_from_user_id_project(
+ project_id,
+ user.id.into(),
+ false,
+ pool,
+ )
+ .await
+ .wrap_internal_err("failed to fetch project team member")?;
+
+ let organization = DBOrganization::get_associated_organization_project_id(
+ project_id, pool,
+ )
+ .await
+ .wrap_internal_err("failed to fetch associated organization")?;
+
+ let organization_team_member = if let Some(organization) = organization {
+ DBTeamMember::get_from_user_id(
+ organization.team_id,
+ user.id.into(),
+ pool,
+ )
+ .await
+ .wrap_internal_err("failed to fetch organization team member")?
+ } else {
+ None
+ };
+
+ let permissions = ProjectPermissions::get_permissions_by_role(
+ &user.role,
+ &team_member,
+ &organization_team_member,
+ )
+ .unwrap_or_default();
+
+ if !permissions.contains(ProjectPermissions::UPLOAD_VERSION) {
+ return Err(ApiError::Auth(eyre!(permission_error)));
+ }
+
+ Ok(())
+}
+
+async fn cleanup_empty_groups(pool: &PgPool) -> Result<(), ApiError> {
+ sqlx::query!(
+ "
+ delete from project_attribution_groups g
+ where not exists (
+ select 1 from project_attribution_files f where f.group_id = g.id
+ )
+ ",
+ )
+ .execute(pool)
+ .await
+ .wrap_internal_err("failed to delete empty attribution groups")?;
+ Ok(())
+}
+
+fn hex_to_bytes(hex: &str) -> Option> {
+ if !hex.len().is_multiple_of(2) {
+ return None;
+ }
+ (0..hex.len())
+ .step_by(2)
+ .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
+ .collect()
+}
diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs
index 2945893401..7fe23ccb73 100644
--- a/apps/labrinth/src/routes/internal/flows.rs
+++ b/apps/labrinth/src/routes/internal/flows.rs
@@ -43,7 +43,6 @@ use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashMap;
use std::str::FromStr;
-use std::sync::Arc;
use tracing::info;
use validator::Validate;
use zxcvbn::Score;
@@ -89,7 +88,7 @@ impl TempUser {
provider: AuthProvider,
transaction: &mut PgTransaction<'_>,
client: &PgPool,
- file_host: &Arc,
+ file_host: &dyn FileHost,
redis: &RedisPool,
) -> Result {
if let Some(email) = &self.email
@@ -156,7 +155,7 @@ impl TempUser {
ext,
Some(96),
Some(1.0),
- &**file_host,
+ file_host,
)
.await;
@@ -1179,7 +1178,7 @@ pub async fn auth_callback(
req: HttpRequest,
Query(query): Query>,
client: Data,
- file_host: Data>,
+ file_host: Data,
redis: Data,
) -> Result {
let state_string = query
@@ -1337,7 +1336,7 @@ pub async fn auth_callback(
provider,
&mut transaction,
&client,
- &file_host,
+ &**file_host,
&redis,
)
.await?
diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs
index afc19bfc86..5bc6531856 100644
--- a/apps/labrinth/src/routes/internal/mod.rs
+++ b/apps/labrinth/src/routes/internal/mod.rs
@@ -1,5 +1,6 @@
pub mod admin;
pub mod affiliate;
+pub mod attribution;
pub mod billing;
pub mod campaign;
pub mod delphi;
@@ -107,5 +108,10 @@ pub fn utoipa_config(
utoipa_actix_web::scope("/_internal/server-ping")
.wrap(default_cors())
.configure(server_ping::config),
+ )
+ .service(
+ utoipa_actix_web::scope("/_internal/attribution")
+ .wrap(default_cors())
+ .configure(attribution::config),
);
}
diff --git a/apps/labrinth/src/routes/internal/moderation/external_license.rs b/apps/labrinth/src/routes/internal/moderation/external_license.rs
index cbcf03b245..6ef567ee34 100644
--- a/apps/labrinth/src/routes/internal/moderation/external_license.rs
+++ b/apps/labrinth/src/routes/internal/moderation/external_license.rs
@@ -5,6 +5,8 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::database::PgPool;
+use crate::database::models::ids::DBUserId;
+use crate::database::models::moderation_external_item::ExternalLicense;
use crate::database::redis::RedisPool;
use crate::models::pats::Scopes;
use crate::queue::moderation::ApprovalType;
@@ -14,7 +16,11 @@ use crate::{auth::check_is_moderator_from_headers, queue::session::AuthQueue};
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(search)
.service(get_by_sha1)
- .service(update_license);
+ .service(get_by_sha1_bulk)
+ .service(lookup)
+ .service(update_license)
+ .service(add_file)
+ .service(reassign_file);
}
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
@@ -43,6 +49,26 @@ pub struct LinkedFile {
pub struct SearchRequest {
pub title: Option,
pub flame_id: Option,
+ pub flame_ids: Option>,
+}
+
+#[derive(Deserialize, utoipa::ToSchema)]
+pub struct HashLookupRequest {
+ pub hashes: Vec,
+}
+
+#[derive(Deserialize, utoipa::ToSchema)]
+pub struct ExternalLicenseLookupRequest {
+ #[serde(default)]
+ pub flame_ids: Vec,
+ #[serde(default)]
+ pub hashes: Vec,
+}
+
+#[derive(Serialize, utoipa::ToSchema)]
+pub struct ExternalLicenseLookupResponse {
+ pub flame_ids: HashMap>,
+ pub hashes: HashMap,
}
#[derive(Deserialize, utoipa::ToSchema)]
@@ -55,6 +81,32 @@ pub struct UpdateLicenseRequest {
pub flame_project_id: Option,
}
+#[derive(Deserialize, utoipa::ToSchema)]
+pub struct FileLicenseRequest {
+ pub hashes: Vec,
+ pub license_id: LicenseId,
+}
+
+#[derive(Deserialize, utoipa::ToSchema)]
+#[serde(untagged)]
+pub enum LicenseId {
+ Number(i64),
+ String(String),
+}
+
+impl LicenseId {
+ fn parse(self) -> Result {
+ match self {
+ LicenseId::Number(id) => Ok(id),
+ LicenseId::String(id) => id.parse().map_err(|_| {
+ ApiError::InvalidInput(
+ "license_id must be a valid integer".to_string(),
+ )
+ }),
+ }
+ }
+}
+
struct LicenseRow {
id: i64,
title: Option,
@@ -69,6 +121,38 @@ struct LicenseRow {
updated_by: Option,
}
+struct LicenseHashRow {
+ hash: Vec,
+ id: i64,
+ title: Option,
+ status: String,
+ link: Option,
+ exceptions: Option,
+ proof: Option,
+ flame_project_id: Option,
+ inserted_at: Option>,
+ inserted_by: Option,
+ updated_at: Option>,
+ updated_by: Option,
+}
+
+fn normalize_sha1_hashes(hashes: &[String]) -> Result, ApiError> {
+ hashes
+ .iter()
+ .map(|hash| {
+ let hash = hash.trim().to_lowercase();
+ if hash.len() != 40 || !hash.chars().all(|c| c.is_ascii_hexdigit())
+ {
+ return Err(ApiError::InvalidInput(
+ "hash must be a valid SHA1 hex string".to_string(),
+ ));
+ }
+
+ Ok(hash)
+ })
+ .collect()
+}
+
impl LicenseRow {
fn into_external_project(
self,
@@ -120,12 +204,131 @@ async fn fetch_linked_files(
.or_default()
.push(LinkedFile {
name: row.filename,
- sha1: hex::encode(&row.sha1),
+ sha1: String::from_utf8(row.sha1)
+ .unwrap_or_else(|err| hex::encode(err.into_bytes())),
});
}
Ok(map)
}
+async fn fetch_by_hashes(
+ pool: &PgPool,
+ hashes: &[String],
+) -> Result, ApiError> {
+ if hashes.is_empty() {
+ return Ok(HashMap::new());
+ }
+
+ let hash_bytes = hashes
+ .iter()
+ .map(|hash| hash.as_bytes().to_vec())
+ .collect::>();
+
+ let rows = sqlx::query_as!(
+ LicenseHashRow,
+ r#"
+ SELECT
+ mef.sha1 hash,
+ mel.id,
+ mel.title,
+ mel.status,
+ mel.link,
+ mel.exceptions,
+ mel.proof,
+ mel.flame_project_id,
+ mel.inserted_at,
+ mel.inserted_by,
+ mel.updated_at,
+ mel.updated_by
+ FROM moderation_external_files mef
+ INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id
+ WHERE mef.sha1 = ANY($1)
+ "#,
+ &hash_bytes,
+ )
+ .fetch_all(pool)
+ .await?;
+
+ let license_ids = rows.iter().map(|row| row.id).collect::>();
+ let files_map = fetch_linked_files(pool, &license_ids).await?;
+
+ let mut results = HashMap::new();
+ for row in rows {
+ let hash = String::from_utf8(row.hash)
+ .unwrap_or_else(|err| hex::encode(err.into_bytes()));
+ let linked_files = files_map.get(&row.id).cloned().unwrap_or_default();
+ results.insert(
+ hash,
+ LicenseRow {
+ id: row.id,
+ title: row.title,
+ status: row.status,
+ link: row.link,
+ exceptions: row.exceptions,
+ proof: row.proof,
+ flame_project_id: row.flame_project_id,
+ inserted_at: row.inserted_at,
+ inserted_by: row.inserted_by,
+ updated_at: row.updated_at,
+ updated_by: row.updated_by,
+ }
+ .into_external_project(linked_files),
+ );
+ }
+
+ Ok(results)
+}
+
+async fn fetch_by_flame_ids(
+ pool: &PgPool,
+ flame_ids: &[i32],
+) -> Result>, ApiError> {
+ if flame_ids.is_empty() {
+ return Ok(HashMap::new());
+ }
+
+ let rows = sqlx::query_as!(
+ LicenseRow,
+ r#"
+ SELECT
+ mel.id,
+ mel.title,
+ mel.status,
+ mel.link,
+ mel.exceptions,
+ mel.proof,
+ mel.flame_project_id,
+ mel.inserted_at,
+ mel.inserted_by,
+ mel.updated_at,
+ mel.updated_by
+ FROM moderation_external_licenses mel
+ WHERE mel.flame_project_id = ANY($1)
+ ORDER BY mel.id
+ "#,
+ flame_ids,
+ )
+ .fetch_all(pool)
+ .await?;
+
+ let license_ids = rows.iter().map(|row| row.id).collect::>();
+ let files_map = fetch_linked_files(pool, &license_ids).await?;
+
+ let mut results: HashMap> = HashMap::new();
+ for row in rows {
+ if let Some(flame_project_id) = row.flame_project_id {
+ let linked_files =
+ files_map.get(&row.id).cloned().unwrap_or_default();
+ results
+ .entry(flame_project_id)
+ .or_default()
+ .push(row.into_external_project(linked_files));
+ }
+ }
+
+ Ok(results)
+}
+
#[utoipa::path]
#[post("/search")]
async fn search(
@@ -144,7 +347,8 @@ async fn search(
)
.await?;
- let rows = sqlx::query!(
+ let rows = sqlx::query_as!(
+ LicenseRow,
r#"
SELECT
mel.id,
@@ -160,11 +364,16 @@ async fn search(
mel.updated_by
FROM moderation_external_licenses mel
WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%')
- AND ($2::integer IS NULL OR mel.flame_project_id = $2)
+ AND (
+ ($2::integer IS NULL AND $3::integer[] IS NULL)
+ OR mel.flame_project_id = $2
+ OR mel.flame_project_id = ANY($3)
+ )
ORDER BY mel.id
"#,
body.title,
body.flame_id,
+ body.flame_ids.as_deref(),
)
.fetch_all(&**pool)
.await?;
@@ -177,26 +386,42 @@ async fn search(
.map(|row| {
let linked_files =
files_map.get(&row.id).cloned().unwrap_or_default();
- LicenseRow {
- id: row.id,
- title: row.title,
- status: row.status,
- link: row.link,
- exceptions: row.exceptions,
- proof: row.proof,
- flame_project_id: row.flame_project_id,
- inserted_at: row.inserted_at,
- inserted_by: row.inserted_by,
- updated_at: row.updated_at,
- updated_by: row.updated_by,
- }
- .into_external_project(linked_files)
+ row.into_external_project(linked_files)
})
.collect();
Ok(web::Json(results))
}
+#[utoipa::path]
+#[post("/lookup")]
+async fn lookup(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ body: web::Json,
+) -> Result, ApiError> {
+ check_is_moderator_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_READ,
+ )
+ .await?;
+
+ let body = body.into_inner();
+ let hashes = normalize_sha1_hashes(&body.hashes)?;
+ let flame_ids = fetch_by_flame_ids(&pool, &body.flame_ids).await?;
+ let hashes = fetch_by_hashes(&pool, &hashes).await?;
+
+ Ok(web::Json(ExternalLicenseLookupResponse {
+ flame_ids,
+ hashes,
+ }))
+}
+
#[utoipa::path]
#[get("/by-sha1/{sha1}")]
async fn get_by_sha1(
@@ -215,48 +440,145 @@ async fn get_by_sha1(
)
.await?;
- let sha1 = path.into_inner().0;
+ let hashes = normalize_sha1_hashes(&[path.into_inner().0])?;
+ let hash = hashes.first().ok_or(ApiError::NotFound)?;
+ let mut results = fetch_by_hashes(&pool, &hashes).await?;
+ let result = results.remove(hash).ok_or(ApiError::NotFound)?;
+
+ Ok(web::Json(result))
+}
+
+#[utoipa::path]
+#[post("/by-sha1")]
+async fn get_by_sha1_bulk(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ body: web::Json,
+) -> Result>, ApiError> {
+ check_is_moderator_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_READ,
+ )
+ .await?;
+
+ let hashes = normalize_sha1_hashes(&body.hashes)?;
+ let results = fetch_by_hashes(&pool, &hashes).await?;
+
+ Ok(web::Json(results))
+}
+
+#[utoipa::path]
+#[post("/file")]
+async fn add_file(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ body: web::Json,
+) -> Result, ApiError> {
+ upsert_file_license(req, pool, redis, session_queue, body).await
+}
+
+#[utoipa::path]
+#[post("/file/reassign")]
+async fn reassign_file(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ body: web::Json,
+) -> Result, ApiError> {
+ upsert_file_license(req, pool, redis, session_queue, body).await
+}
+
+async fn upsert_file_license(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ body: web::Json,
+) -> Result, ApiError> {
+ let user = check_is_moderator_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_READ,
+ )
+ .await?;
+
+ let body = body.into_inner();
+ let license_id = body.license_id.parse()?;
+ if body.hashes.is_empty() {
+ return Err(ApiError::InvalidInput(
+ "hashes must contain at least one SHA1 hex string".to_string(),
+ ));
+ }
+ let hashes = normalize_sha1_hashes(&body.hashes)?;
+ let hash_bytes = hashes
+ .iter()
+ .map(|hash| hash.as_bytes().to_vec())
+ .collect::>();
+ let filenames = vec![None; hashes.len()];
+ let license_ids = vec![license_id; hashes.len()];
+
+ let mut transaction = pool.begin().await?;
- let row = sqlx::query!(
+ let license = sqlx::query!(
r#"
SELECT
- mel.id,
- mel.title,
- mel.status,
- mel.link,
- mel.exceptions,
- mel.proof,
- mel.flame_project_id,
- mel.inserted_at,
- mel.inserted_by,
- mel.updated_at,
- mel.updated_by
- FROM moderation_external_files mef
- INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id
- WHERE mef.sha1 = $1
+ id,
+ title,
+ status,
+ link,
+ exceptions,
+ proof,
+ flame_project_id,
+ inserted_at,
+ inserted_by,
+ updated_at,
+ updated_by
+ FROM moderation_external_licenses
+ WHERE id = $1
"#,
- sha1.as_bytes().to_vec(),
+ license_id,
)
- .fetch_optional(&**pool)
+ .fetch_optional(&mut transaction)
.await?
.ok_or(ApiError::NotFound)?;
- let files_map = fetch_linked_files(&pool, &[row.id]).await?;
- let linked_files = files_map.get(&row.id).cloned().unwrap_or_default();
+ ExternalLicense::insert_files(
+ &mut transaction,
+ &hash_bytes,
+ &filenames,
+ &license_ids,
+ DBUserId(user.id.0 as i64),
+ )
+ .await?;
+
+ transaction.commit().await?;
+
+ let files_map = fetch_linked_files(&pool, &[license_id]).await?;
+ let linked_files = files_map.get(&license_id).cloned().unwrap_or_default();
Ok(web::Json(
LicenseRow {
- id: row.id,
- title: row.title,
- status: row.status,
- link: row.link,
- exceptions: row.exceptions,
- proof: row.proof,
- flame_project_id: row.flame_project_id,
- inserted_at: row.inserted_at,
- inserted_by: row.inserted_by,
- updated_at: row.updated_at,
- updated_by: row.updated_by,
+ id: license.id,
+ title: license.title,
+ status: license.status,
+ link: license.link,
+ exceptions: license.exceptions,
+ proof: license.proof,
+ flame_project_id: license.flame_project_id,
+ inserted_at: license.inserted_at,
+ inserted_by: license.inserted_by,
+ updated_at: license.updated_at,
+ updated_by: license.updated_by,
}
.into_external_project(linked_files),
))
diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs
index 05fae0e884..cfa609a98e 100644
--- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs
+++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs
@@ -290,13 +290,13 @@ async fn get_report(
'flag_reason', 'delphi',
'download_url', f.url,
-- TODO: replace with `json_array` in Postgres 16
- 'issues', (
- SELECT json_agg(
- to_jsonb(dri)
- || jsonb_build_object(
- -- TODO: replace with `json_array` in Postgres 16
- 'details', (
- SELECT coalesce(jsonb_agg(
+ 'issues', (
+ SELECT coalesce(json_agg(
+ to_jsonb(dri)
+ || jsonb_build_object(
+ -- TODO: replace with `json_array` in Postgres 16
+ 'details', (
+ SELECT coalesce(jsonb_agg(
jsonb_build_object(
'id', didws.id,
'issue_id', didws.issue_id,
@@ -311,11 +311,11 @@ async fn get_report(
FROM delphi_issue_details_with_statuses didws
WHERE didws.issue_id = dri.id
)
- )
- )
- FROM delphi_report_issues dri
- WHERE
- dri.report_id = dr.id
+ )
+ ), '[]'::json)
+ FROM delphi_report_issues dri
+ WHERE
+ dri.report_id = dr.id
-- see delphi.rs todo comment
AND dri.issue_type != '__dummy'
)
diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs
index 178a8a706b..f5cfdc1ad9 100644
--- a/apps/labrinth/src/routes/mod.rs
+++ b/apps/labrinth/src/routes/mod.rs
@@ -97,7 +97,8 @@ pub enum ApiError {
FileHosting(#[from] FileHostingError),
#[error("database error")]
Database(#[from] crate::database::models::DatabaseError),
- #[error("Postgres database error")]
+ // todo: remove this variant
+ #[error("Postgres database error: {0}")]
SqlxDatabase(#[from] sqlx::Error),
#[error("redis database error")]
RedisDatabase(#[from] redis::RedisError),
diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs
index ded3fd4f05..d6afd30cc8 100644
--- a/apps/labrinth/src/routes/v2/project_creation.rs
+++ b/apps/labrinth/src/routes/v2/project_creation.rs
@@ -21,7 +21,6 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
-use std::sync::Arc;
use validator::Validate;
use super::version_creation::InitialVersionData;
@@ -159,7 +158,7 @@ pub async fn project_create(
payload: Multipart,
client: Data,
redis: Data,
- file_host: Data>,
+ file_host: Data,
session_queue: Data,
http: Data,
search_state: Data,
diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs
index 77deda18de..700001b5f3 100644
--- a/apps/labrinth/src/routes/v2/projects.rs
+++ b/apps/labrinth/src/routes/v2/projects.rs
@@ -18,7 +18,6 @@ use crate::search::{SearchBackend, SearchRequest, SearchState};
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
-use std::sync::Arc;
use validator::Validate;
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
@@ -927,7 +926,7 @@ pub async fn project_icon_edit(
info: web::Path<(String,)>,
pool: web::Data,
redis: web::Data,
- file_host: web::Data>,
+ file_host: web::Data,
payload: web::Payload,
session_queue: web::Data,
search_state: web::Data,
@@ -969,7 +968,7 @@ pub async fn delete_project_icon(
info: web::Path<(String,)>,
pool: web::Data,
redis: web::Data,
- file_host: web::Data