Compare commits

...
Author SHA1 Message Date
aecsocket 9e4e1aef41 Merge branch 'main' into boris/dev-886-modpack-project 2026-06-23 20:19:50 +01:00
Prospector f164bbbc3e update blog link 2026-06-23 11:53:22 -07:00
aecsocket 2a745a090c Expose route for rescanning 2026-06-23 18:58:42 +01:00
aecsocket 49fbbc4212 idempotency 2026-06-23 18:44:17 +01:00
aecsocket 8215129e55 fix 2026-06-23 00:18:48 +01:00
Prospector 21fdd30919 QA Jun 22 2026-06-22 15:58:51 -07:00
aecsocket 7fcf5b8c82 add more logging 2026-06-22 23:41:07 +01:00
aecsocket 2f607774e7 change what files get cscanned 2026-06-22 23:19:16 +01:00
aecsocket 079381246b Fix permission error 2026-06-22 19:37:40 +01:00
aecsocket fadd3cf7f8 fix cross project attribution stuff 2026-06-22 18:19:49 +01:00
aecsocket b24a45ba8c fix ci 2026-06-22 18:10:54 +01:00
aecsocket 4b2b5a56c1 Ignore Flame projects which 404 2026-06-22 17:59:22 +01:00
aecsocket 5aafa40f31 allow exempting projects 2026-06-22 17:30:19 +01:00
aecsocket 5b80b71be4 Merge branch 'main' into boris/dev-886-modpack-project 2026-06-22 17:07:32 +01:00
Prospector f9f569bec6 QA 2026-06-20 12:08:46 -07:00
Prospector 1943fe4f80 Merge branch 'main' into boris/dev-886-modpack-project 2026-06-19 12:44:30 -07:00
Prospector 992c206200 Merge branch 'main' into boris/dev-886-modpack-project 2026-06-12 07:53:29 -07:00
aecsocket 2b38ea6076 prepare 2026-06-11 21:30:34 +01:00
aecsocket 2a873e4500 Merge branch 'main' into boris/dev-886-modpack-project 2026-06-11 21:27:34 +01:00
coolbot100s b2408eec0d Quick replies for external content permissions. 2026-06-08 18:36:19 -07:00
coolbot100s 895e7aabc6 Reorganize moderation messages. 2026-06-08 17:23:11 -07:00
Prospector 3970b4e3ae move supplementary resources 2026-06-08 15:20:48 -07:00
aecsocket 722e35fe28 don't redact moderated_at 2026-06-08 17:40:06 +01:00
Prospector 7e54d9df9f format? 2026-06-07 23:27:59 -07:00
Prospector 5fc8171711 provide moderation package stuff with DI 2026-06-07 23:23:34 -07:00
Prospector 4943d71b68 prepr 2026-06-07 22:35:04 -07:00
Prospector 7f972c4bc3 Merge remote-tracking branch 'origin/main' into boris/dev-886-modpack-project
# Conflicts:
#	apps/frontend/src/composables/featureFlags.ts
#	apps/frontend/src/locales/es-419/index.json
2026-06-07 22:34:40 -07:00
Prospector 5050da81e0 qa items 2026-06-07 22:32:50 -07:00
Prospector a6be110734 clean up fetching + add a couple missing features 2026-06-07 12:49:01 -07:00
Prospector f135f121e3 new version page 2026-06-07 09:49:51 -07:00
aecsocket 9834e86d4f default deser FMA field 2026-06-07 17:07:05 +01:00
aecsocket 471b67fe5a Track what moderator reviewed an attribution moderation status 2026-06-02 13:14:57 +01:00
aecsocket ed4d7594d7 updated by moderator info 2026-06-02 12:43:20 +01:00
aecsocket cccaf1eee6 return attribution resolution correctly 2026-06-02 12:19:05 +01:00
aecsocket e2f2fd8e65 should group by project 2026-06-01 23:21:50 +01:00
aecsocket 888d70f629 Merge branch 'main' into boris/dev-886-modpack-project 2026-06-01 17:21:31 +01:00
Prospector 04bdbd2d0a prepr 2026-05-28 17:47:08 -07:00
Prospector 78b617c25c Merge remote-tracking branch 'origin/main' into boris/dev-886-modpack-project
# Conflicts:
#	packages/assets/generated-icons.ts
2026-05-28 17:46:41 -07:00
Prospector dc1c3c8662 QA 2026-05-28 15:49:22 -07:00
2175be5bda feat: coolbot moderation changes (#6215)
* Update moderator checklist

* move permissions stage order

* Updated nagContext.versions to v3, added nag for permissions

* Update permissions.vue default messages

* prepr

---------

Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
2026-05-28 11:46:22 -07:00
aecsocket edb82b5304 Merge branch 'main' into boris/dev-886-modpack-project 2026-05-27 18:19:39 +01:00
aecsocket 4653b0c803 fix how hex bytes are handled in route 2026-05-27 12:25:42 +01:00
aecsocket 7a420f03f4 Prioritise putting override files in existing groups even with ExternalLicense 2026-05-27 12:14:06 +01:00
Prospector ecd77812ce delete file with weird name 2026-05-25 16:11:48 -07:00
aecsocket 8bc26f2cf1 query flame even if entry in MEL 2026-05-25 15:33:50 +01:00
aecsocket 99c77d756e change hash insert to bulk route 2026-05-25 14:42:18 +01:00
aecsocket af2224d44b include link in NoPermission 2026-05-25 14:37:37 +01:00
Prospector c8b64df900 pushing what i've got rn 2026-05-24 18:13:26 -07:00
Prospector 8658dbf3db Merge remote-tracking branch 'origin/main' into boris/dev-886-modpack-project 2026-05-24 12:14:38 -07:00
Prospector e8114a3614 Merge remote-tracking branch 'origin/main' into boris/dev-886-modpack-project 2026-05-24 11:41:15 -07:00
aecsocket 3cd1a06243 wip: bulk routes 2026-05-23 16:32:00 +01:00
aecsocket 145c79fa51 prepare 2026-05-21 16:51:02 +01:00
aecsocket 690f5c148b Version grandfathering 2026-05-21 16:50:25 +01:00
aecsocket cee57ffe6e fix routes 2026-05-21 16:01:20 +01:00
aecsocket 84bcf366b1 routes 2026-05-21 15:46:38 +01:00
aecsocket 2fecbe9c95 fixes 2026-05-21 15:45:25 +01:00
aecsocket 578b8df720 sqlx prepare 2026-05-21 15:30:58 +01:00
aecsocket 611e308134 fixes 2026-05-21 15:29:58 +01:00
aecsocket 549e9d5554 attribution on dependencies 2026-05-21 15:29:58 +01:00
Prospector 25c63b542c prepr 2026-05-20 11:42:52 -07:00
Prospector 61209ac0e1 update frontend stuff to new schema 2026-05-20 11:21:36 -07:00
Prospector 9071f7dee7 fix admonition 2026-05-20 11:09:04 -07:00
Prospector 50eaecf86d Merge remote-tracking branch 'origin/main' into boris/dev-886-modpack-project 2026-05-20 11:04:20 -07:00
aecsocket 6de97cc0a8 prepare 2026-05-19 17:06:08 +01:00
aecsocket 0bfa16295d fix deletion and file fetch 2026-05-19 16:22:15 +01:00
aecsocket 5d7a77cdfc Merge branch 'main' into boris/dev-886-modpack-project 2026-05-19 14:22:44 +01:00
aecsocket 876a4b649d Merge branch 'main' into boris/dev-886-modpack-project 2026-05-19 14:03:19 +01:00
aecsocket e16884aec5 fmt 2026-05-17 12:50:41 +01:00
aecsocket e11cd519cd fix schema 2026-05-16 18:18:06 +01:00
Prospector 9ae1ddc5ab remove feature flag, fix optional proof images 2026-05-16 11:11:40 -07:00
aecsocket cf02d151cc changes 2026-05-16 17:57:07 +01:00
aecsocket 61ff32a58b Merge branch 'main' into boris/dev-886-modpack-project 2026-05-16 16:46:34 +01:00
aecsocket 8d82bf38b1 sqlx prepare 2026-05-16 16:45:40 +01:00
aecsocket 2d3b4ab54c Add schema to attribution resolution and Flame project results 2026-05-16 16:36:20 +01:00
Prospector 3eb76da8af prepr 2026-05-16 00:20:49 -07:00
Prospector 00993a3ff8 moar frontend 2026-05-16 00:20:00 -07:00
Prospector 1f28196649 use api-client for img upload 2026-05-15 09:55:42 -07:00
ProspectorandGitHub 030dc2b3f7 Merge branch 'main' into boris/dev-886-modpack-project 2026-05-15 09:44:39 -07:00
Prospector 3801039771 prepr 2026-05-15 09:44:13 -07:00
Prospector c75e17ec0e frontend work 2026-05-15 09:26:54 -07:00
aecsocket 7315c35a41 withholding version visibility 2026-05-14 19:37:29 +01:00
aecsocket c07b7afef6 expose version date published 2026-05-09 15:18:55 +01:00
Prospector 0d488d162e Merge remote-tracking branch 'origin/main' into boris/dev-886-modpack-project 2026-05-09 03:03:18 -07:00
Prospector 867aadc2e5 hook up frontend to backend (mostly) 2026-05-09 03:03:04 -07:00
aecsocket b6b5271e11 return extended version info + attributed at/by 2026-05-07 15:46:08 +01:00
aecsocket 2ee217bdff add files_missing_attributions to versions 2026-05-07 15:44:13 +01:00
aecsocket 9819611217 wip: override file sources 2026-05-07 15:44:12 +01:00
Prospector 1fdaf8e001 prepr 2026-05-06 22:22:25 -07:00
aecsocket 84888dc788 remove dep sha1 stuff 2026-05-06 13:12:39 +01:00
aecsocket 91d74e7705 Merge branch 'main' into boris/dev-886-modpack-project 2026-05-06 10:57:19 +01:00
Prospector a0471dad2b Merge remote-tracking branch 'origin/main' into boris/dev-886-modpack-project
# Conflicts:
#	apps/frontend/src/composables/featureFlags.ts
#	apps/frontend/src/pages/[type]/[id]/settings.vue
#	apps/frontend/src/pages/[type]/[id]/settings/permissions.vue
#	apps/frontend/src/pages/[type]/[id]/settings/versions.vue
#	apps/frontend/src/pages/moderation/external-projects.vue
#	packages/ui/src/components/external_files/ExternalProjectLookupCard.vue
#	packages/ui/src/components/external_files/index.ts
2026-05-05 13:01:07 -07:00
aecsocket bfd592487e add routes 2026-05-03 17:26:53 +01:00
aecsocket d53d5c4538 Merge branch 'main' into boris/dev-886-modpack-project 2026-05-03 13:12:16 +01:00
aecsocket e30cf265a4 insert attribution info after resolving 2026-05-03 13:10:49 +01:00
aecsocket 1b26f55f1e works 2026-05-02 14:13:21 +01:00
aecsocket d983f115ed attribution scanning basic works 2026-05-02 13:28:33 +01:00
aecsocket f01d149603 update s3 file host 2026-05-02 12:55:20 +01:00
aecsocket b93f6c682c wip: attributions 2026-05-02 12:51:33 +01:00
aecsocket 220733fa2f clean up version files 2026-05-02 12:38:29 +01:00
aecsocket e33b1dcec5 add dependency sha1 field 2026-05-01 22:12:30 +01:00
aecsocket 796049f6b0 Merge branch 'main' into boris/dev-886-modpack-project 2026-05-01 20:42:25 +01:00
aecsocket 15f67eae3d scan files when inserting version file 2026-04-25 14:28:58 +01:00
aecsocket d846ef7f7d file host read capability 2026-04-25 14:28:58 +01:00
aecsocket 8d4076f8f0 improve FileHost ref counting 2026-04-25 14:28:58 +01:00
aecsocket bc9e586092 wip: override license checks 2026-04-25 14:28:58 +01:00
aecsocket e1f44b861e wip: when uploading a version file, fetch its overrides as a list 2026-04-25 14:28:58 +01:00
aecsocket 81abcf6403 include filename in external files 2026-04-25 14:28:58 +01:00
aecsocket 873a12bae5 MEL database backend routes 2026-04-25 14:28:58 +01:00
Prospectorandaecsocket 2d2598b221 begin project page permissions settings 2026-04-25 14:28:58 +01:00
Prospectorandaecsocket 804f6ca031 add copy link button 2026-04-25 14:28:58 +01:00
Prospectorandaecsocket fd28721d35 Begin external projects moderator database frontend 2026-04-25 14:28:57 +01:00
315 changed files with 11106 additions and 2209 deletions
+1
View File
@@ -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"
@@ -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 {
@@ -24,7 +24,7 @@
</div>
<div class="input-group">
<ButtonStyled circular>
<button :class="!collapsed && '[&>svg]:rotate-180'" @click="$emit('toggleCollapsed')">
<button :class="{ '[&>svg]:rotate-180': !collapsed }" @click="$emit('toggleCollapsed')">
<DropdownIcon class="duration-250 transition-transform ease-in-out" />
</button>
</ButtonStyled>
@@ -59,7 +59,7 @@
</ButtonStyled>
<ButtonStyled v-if="visibleQuickReplies.length > 0">
<OverflowMenu :options="visibleQuickReplies">
Quick Reply
Quick reply
<ChevronDownIcon />
</OverflowMenu>
</ButtonStyled>
@@ -48,7 +48,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
useV1ContentTabAPI: true,
labrinthApiCanary: false,
dismissedExternalProjectsInfo: false,
modpackPermissionsPage: false,
showAllBanners: false,
alwaysIgnoreErrorBanner: false,
showViewProdRouteBanner: false,
@@ -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
@@ -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,
}),
}
@@ -3290,9 +3290,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Wenn du Hilfe benötigst oder weitere Fragen hast, besuche bitte das <help-center-link>Hilfezentrum von Modrinth</help-center-link> 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"
},
@@ -3290,9 +3290,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Falls du Hilfe benötigst oder weitere Fragen hast, besuche bitte das <help-center-link>Hilfezentrum von Modrinth</help-center-link> 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"
},
+136 -16
View File
@@ -3332,9 +3332,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "If you need assistance or have additional inquiries, please visit the <help-center-link>Modrinth Help Center</help-center-link> 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 isnt hosted on Modrinth, you need to let us know where its from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn about how to do this properly!"
"message": "If you include content that isnt hosted on Modrinth, you need to let us know where its from and verify that you have permission to distribute the files. Check out <link>our announcement of this new system</link> 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-link>Gradle</gradle-link>. To learn more about the Modrinth Maven API, <article-link>click here</article-link>."
},
"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"
}
}
@@ -3332,9 +3332,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Si necesitas ayuda o tienes consultas adicionales, por favor visita el <help-center-link>Centro de ayuda de Modrinth</help-center-link> 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"
},
@@ -3326,9 +3326,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Si necesitas ayuda o tienes consultas adicionales, por favor visita el <help-center-link>Centro de ayuda de Modrinth</help-center-link> 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"
},
@@ -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 <help-center-link>Modrinth Help Center</help-center-link> 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"
},
@@ -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"
},
@@ -3323,9 +3323,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Se hai bisogno di assistenza o hai altre richieste, visita il <help-center-link>centro assistenza di Modrinth</help-center-link> 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"
},
@@ -2768,9 +2768,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "도움이 필요하거나 추가 문의 사항이 있다면 <help-center-link>Modrinth 도움말 센터</help-center-link>를 방문하여 파란색 말풍선을 클릭해 지원 센터에 문의해 주세요."
},
"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 {버전}}은 공개되지 않았습니다"
},
@@ -3143,9 +3143,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Jika anda memerlukan bantuan atau mempunyai pertanyaan tambahan, sila lawati <help-center-link>Pusat Bantuan Modrinth</help-center-link> 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"
},
@@ -3326,9 +3326,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Jeżeli potrzebujesz pomocy lub masz więcej pytań, odwiedź <help-center-link>centrum pomocy Modrinth</help-center-link> 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ść"
},
@@ -3323,9 +3323,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Se precisar de ajuda ou tiver dúvidas adicionais, visite a <help-center-link>Central de Ajuda do Modrinth</help-center-link> 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"
},
@@ -3314,9 +3314,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Если вам нужна помощь или у вас есть дополнительные вопросы, посетите <help-center-link>справочный центр Modrinth</help-center-link> и нажмите на синий значок, чтобы обратиться в поддержку."
},
"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 {Версии скрыты}} из-за неизвестного встроенного контента"
},
@@ -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 <help-center-link>Modrinth Yardım Merkezi'ni</help-center-link> 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"
},
@@ -3074,9 +3074,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "Якщо вам потрібна допомога або у вас є додаткові запитання, відвідайте <help-center-link>довідковий центр Modrinth</help-center-link> і натисніть синю підказку, щоб зв’язатися зі службою підтримки."
},
"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 {Версії}} утримано через невідомий убудований уміст"
},
@@ -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 <help-center-link>trung tâm trợ giúp</help-center-link> 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"
},
@@ -3284,9 +3284,6 @@
"project.moderation.thread.help-center-note.2": {
"message": "如果你需要帮助或有其他疑问,请访问 <help-center-link>Modrinth Help Center</help-center-link>,然后点击蓝色气泡以联系支持。"
},
"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 {这些版本}} 因未知嵌入内容被保留"
},
@@ -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 {版本}}因未知的嵌入內容而被限制存取"
},
+3 -1
View File
@@ -881,7 +881,9 @@
class="card flex-card"
/>
<ProjectSidebarCompatibility
v-if="projectV3Loaded && !isServerProject"
v-if="
projectV3Loaded && !isServerProject && route.name !== 'type-project-version-version'
"
:project="project"
:tags="tags"
:project-v3="projectV3"
@@ -72,11 +72,9 @@
<h2 id="messages" class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.threadSectionTitle) }}
</h2>
<div v-if="currentMember?.staffOnly" class="flex items-center gap-2">
<div v-if="isStaff(currentMember?.user)" class="flex items-center gap-2">
<Toggle id="moderator-see-user-ui-toggle" v-model="moderatorSeeUserUi" small />
<label for="moderator-see-user-ui-toggle">
{{ formatMessage(messages.moderatorSeeUserUiToggle) }}
</label>
<label for="moderator-see-user-ui-toggle"> Show member UI </label>
</div>
</div>
<template v-if="userFacingUiVisible">
@@ -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<ProjectPageMember | null>
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<MessageDescriptor | null>(() => {
switch (project.value?.status) {
@@ -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<boolean>({
get() {
return flags.value.showModeratorProjectMemberUi
},
set(value: boolean) {
flags.value.showModeratorProjectMemberUi = value
saveFeatureFlags()
},
})
</script>
<template>
@@ -167,6 +176,10 @@ watch(route, () => {
<div class="grid gap-4 lg:grid-cols-[1fr_3fr]">
<div>
<NavStack :items="navItems" />
<div v-if="isStaff(currentMember?.user)" class="mt-4 flex items-center gap-2">
<Toggle id="moderator-see-user-ui-toggle" v-model="moderatorSeeUserUi" small />
<label for="moderator-see-user-ui-toggle"> Show member UI </label>
</div>
</div>
<div class="min-w-0">
<NuxtPage />
@@ -1,51 +1,303 @@
<script setup lang="ts">
import { RightArrowIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import type { Labrinth } from '@modrinth/api-client'
import {
ArrowDown10Icon,
ArrowDownWideNarrowIcon,
ClockArrowDownIcon,
FoldVerticalIcon,
PauseIcon,
RightArrowIcon,
ScaleIcon,
SearchIcon,
UnfoldVerticalIcon,
XCircleIcon,
} from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
Combobox,
type ComboboxOption,
commonMessages,
createAttributionGroupTitle,
defineMessage,
defineMessages,
EmptyState,
ExternalProjectPermissionsCard,
injectModrinthClient,
injectProjectPageContext,
IntlFormatted,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import ExternalProjectPermissionsCard from '@modrinth/ui/src/components/external_files/ExternalProjectPermissionsCard.vue'
import { ref } from 'vue'
import { isStaff } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
const { formatMessage } = useVIntl()
import { setupAttributionModerationProvider } from '~/providers/setup/attribution-moderation'
setupAttributionModerationProvider()
const auth = await useAuth()
const flags = useFeatureFlags()
if (!flags.value.modpackPermissionsPage) {
throw createError({
fatal: true,
statusCode: 404,
})
const isModerator = computed(() => {
return isStaff(auth.value?.user) && !flags.value.showModeratorProjectMemberUi
})
const { formatMessage } = useVIntl()
const { projectV2: project } = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
type SortType = 'status' | 'most_files' | 'recently_edited' | 'rejected'
const searchQuery = ref('')
const currentSortType = ref<SortType>('status')
const cardCollapsedById = ref<Record<string, boolean>>({})
const {
data: attributionData,
error: attributionError,
isPending: pending,
} = useQuery<Labrinth.Attribution.Internal.AttributionGroup[]>({
queryKey: ['project-attribution', project.value.id],
queryFn: () => labrinth.attribution_internal.listProjectAttribution(project.value.id),
})
const sortRejectedLabel = defineMessage({
id: 'project.settings.permissions.sort.rejected',
defaultMessage: 'Rejected',
})
const sortTypes = computed<ComboboxOption<SortType>[]>(() => {
const options: ComboboxOption<SortType>[] = [
{
value: 'status',
label: formatMessage(
defineMessage({
id: 'project.settings.permissions.sort.status',
defaultMessage: 'Status',
}),
),
},
{
value: 'most_files',
label: formatMessage(
defineMessage({
id: 'project.settings.permissions.sort.most-files',
defaultMessage: 'Most files',
}),
),
},
{
value: 'recently_edited',
label: formatMessage(
defineMessage({
id: 'project.settings.permissions.sort.recently-edited',
defaultMessage: 'Recently edited',
}),
),
},
]
if (hasMixedModerationStatus.value) {
options.push({
value: 'rejected',
label: formatMessage(sortRejectedLabel),
})
}
return options
})
const currentSortLabel = computed(() => {
return sortTypes.value.find((option) => option.value === currentSortType.value)?.label ?? ''
})
function isAttributed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
return !!group.attribution && !isNoPermission(group)
}
const externalFiles = ref([{}])
const searchQuery = ref('')
const currentSortType = ref('Oldest')
function isNoPermission(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
return group.attribution?.kind === 'no_permission'
}
function isModerationRejected(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
const kind = group.attribution?.moderation_status?.kind
return kind === 'bad_proof' || kind === 'not_allowed'
}
function isNotAllowed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
return group.attribution?.moderation_status?.kind === 'not_allowed'
}
function isBadProof(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
return group.attribution?.moderation_status?.kind === 'bad_proof'
}
function needsModerationApproval(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
const attribution = group.attribution
if (!attribution || attribution.kind === 'globally_allowed') {
return false
}
return !attribution.moderation_status
}
function rejectedSortRank(group: Labrinth.Attribution.Internal.AttributionGroup): number {
if (isModerationRejected(group)) {
return 0
}
if (group.attribution?.moderation_status?.kind === 'approved') {
return 1
}
return 2
}
const hasMixedModerationStatus = computed(() => {
const groups = attributionData.value ?? []
let hasRejected = false
let hasNonRejected = false
for (const group of groups) {
if (isModerationRejected(group)) {
hasRejected = true
} else {
hasNonRejected = true
}
if (hasRejected && hasNonRejected) {
return true
}
}
return false
})
watch(hasMixedModerationStatus, (mixed) => {
if (!mixed && currentSortType.value === 'rejected') {
currentSortType.value = 'status'
}
})
function statusSortRank(group: Labrinth.Attribution.Internal.AttributionGroup): number {
if (isNoPermission(group)) {
return 0
} else if (!group.attribution) {
return 1
} else if (group.attribution?.kind === 'globally_allowed') {
return 3
} else {
return 2
}
}
function alphabetSortKey(group: Labrinth.Attribution.Internal.AttributionGroup): string {
return createAttributionGroupTitle(group, formatMessage)
}
function compareAlphabetical(
a: Labrinth.Attribution.Internal.AttributionGroup,
b: Labrinth.Attribution.Internal.AttributionGroup,
): number {
return alphabetSortKey(a).localeCompare(alphabetSortKey(b), undefined, { sensitivity: 'base' })
}
function attributedTimestamp(group: Labrinth.Attribution.Internal.AttributionGroup): number {
if (!group.attributed_at) return Number.NEGATIVE_INFINITY
const t = Date.parse(group.attributed_at)
return Number.isNaN(t) ? Number.NEGATIVE_INFINITY : t
}
function groupMatchesPermissionsSearch(
group: Labrinth.Attribution.Internal.AttributionGroup,
queryTrimmed: string,
): boolean {
const query = queryTrimmed.toLowerCase()
if (group.flame_project?.title?.toLowerCase().includes(query)) {
return true
}
return (group.files ?? []).some((file) => file.name.toLowerCase().includes(query))
}
const filteredGroups = computed(() => {
const groups = attributionData.value ?? []
const queryTrimmed = searchQuery.value.trim()
const filtered = queryTrimmed
? groups.filter((group) => groupMatchesPermissionsSearch(group, queryTrimmed))
: [...groups]
const sortMode = currentSortType.value
filtered.sort(compareAlphabetical)
filtered.sort((a, b) => {
if (sortMode === 'status') {
return statusSortRank(a) - statusSortRank(b)
}
if (sortMode === 'most_files') {
const ac = a.files?.length ?? 0
const bc = b.files?.length ?? 0
return bc - ac
}
if (sortMode === 'rejected') {
return rejectedSortRank(a) - rejectedSortRank(b)
}
const at = attributedTimestamp(a)
const bt = attributedTimestamp(b)
return bt - at
})
return filtered
})
const totalGroups = computed(() => attributionData.value?.length ?? 0)
const stats = computed(() => {
const groups = attributionData.value ?? []
let attributed = 0
let pending = 0
let noPermission = 0
for (const group of groups) {
if (isNoPermission(group)) {
noPermission++
} else if (isAttributed(group)) {
attributed++
} else {
pending++
}
}
return { total: groups.length, attributed, pending, noPermission }
})
const pendingApprovalCount = computed(() => {
const groups = attributionData.value ?? []
return groups.filter(needsModerationApproval).length
})
const noPermissionCount = computed(() => {
const groups = attributionData.value ?? []
return groups.filter((group) => group.attribution?.kind === 'no_permission').length
})
const notAllowedCount = computed(() => {
const groups = attributionData.value ?? []
return groups.filter(isNotAllowed).length
})
const badProofCount = computed(() => {
const groups = attributionData.value ?? []
return groups.filter(isBadProof).length
})
const pendingCount = computed(() => {
const groups = attributionData.value ?? []
return groups.filter((group) => !group.attribution || isModerationRejected(group)).length
})
const projectIsApproved = computed(() => project.value.status === 'approved')
const sortTypes: ComboboxOption<string>[] = [
{ value: 'Oldest', label: 'Oldest' },
{ value: 'Newest', label: 'Newest' },
]
const messages = defineMessages({
searchPlaceholder: {
id: 'project.settings.permissions.search-placeholder',
defaultMessage:
'Search {count} {count, plural, one {external project} other {external projects}}...',
defaultMessage: 'Search {count} {count, plural, one {project} other {projects}}...',
},
infoBannerTitle: {
id: 'project.settings.permissions.info-banner.title',
defaultMessage: 'Learn how attributions work',
defaultMessage: 'Learn about distribution permissions',
},
infoBannerDescription: {
id: 'project.settings.permissions.info-banner.description',
defaultMessage: `If you include content that isnt hosted on Modrinth, you need to let us know where its from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn about how to do this properly!`,
defaultMessage: `If you include content that isnt hosted on Modrinth, you need to let us know where its from and verify that you have permission to distribute the files. Check out <link>our announcement of this new system</link> to learn more!`,
},
learnMore: {
id: 'project.settings.permissions.learn-more',
@@ -57,15 +309,16 @@ const messages = defineMessages({
},
emptyStateDescription: {
id: 'project.settings.permissions.empty-state.description',
defaultMessage: `None of your versions contain external content, so you don't need to worry about obtaining permissions.`,
defaultMessage: `None of your project's versions contain external content, so you don't need to worry about obtaining permissions.`,
},
completedTitle: {
id: 'project.settings.permissions.completed.title',
defaultMessage: `Attributions completed!`,
defaultMessage: `Permissions completed!`,
},
completedDescription: {
id: 'project.settings.permissions.completed.description',
defaultMessage: 'All external content has attributions provided.',
defaultMessage:
'All external content has permission information and attributions have been provided.',
},
failTitle: {
id: 'project.settings.permissions.fail.title',
@@ -73,107 +326,280 @@ const messages = defineMessages({
},
failDescription: {
id: 'project.settings.permissions.fail.description',
defaultMessage: `You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content.`,
defaultMessage: `You may not have permission to redistribute some of the external content in your project. In order to publish on Modrinth, please remove this content or provide proof that you do have permission to use it.`,
},
notAllowedDescription: {
id: 'project.settings.permissions.not-allowed.description',
defaultMessage: `Some of the external content included cannot be distributed on Modrinth because it violates our Content Rules and must be removed.`,
},
badProofTitle: {
id: 'project.settings.permissions.bad-proof.title',
defaultMessage: `Some proofs were rejected`,
},
badProofDescription: {
id: 'project.settings.permissions.bad-proof.description',
defaultMessage: `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.`,
},
attentionNeededTitle: {
id: 'project.settings.permissions.attention-needed.title',
defaultMessage: `Unknown embedded content`,
defaultMessage: `Unknown external content`,
},
attentionNeededDescriptionApproved: {
id: 'project.settings.permissions.attention-needed.description.proj-approved',
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files and any withheld versions will be automatically published.`,
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files. Once completed, withheld versions will be automatically published.`,
},
attentionNeededDescriptionDraft: {
id: 'project.settings.permissions.attention-needed.description.proj-draft',
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files before you can submit your project for review.`,
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files before submitting your project for review.`,
},
noResults: {
id: 'project.settings.permissions.no-results',
defaultMessage: 'No external files match your search.',
},
collapseAll: {
id: 'project.settings.permissions.collapse-all',
defaultMessage: 'Collapse all',
},
expandAll: {
id: 'project.settings.permissions.expand-all',
defaultMessage: 'Expand all',
},
pendingApprovalCount: {
id: 'project.settings.permissions.pending-approval-count',
defaultMessage:
'{count, plural, =0 {No attributions need approval} one {# attribution needs approval} other {# attributions need approval}}',
},
})
function defaultCardCollapsed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
if (group?.attribution?.kind === 'globally_allowed') {
return true
}
if (!isModerator.value) {
const hasAttribution = !!group.attribution
const rejectedProof = group.attribution?.moderation_status?.kind === 'bad_proof'
return hasAttribution && !rejectedProof
}
return false
}
function getCardCollapsed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
return cardCollapsedById.value[group.id] ?? defaultCardCollapsed(group)
}
function setCardCollapsed(groupId: string, collapsed: boolean) {
cardCollapsedById.value = { ...cardCollapsedById.value, [groupId]: collapsed }
}
const allCardsCollapsed = computed(() => {
const groups = filteredGroups.value
if (groups.length === 0) {
return true
}
return groups.every((group) => getCardCollapsed(group))
})
const expandCollapseAllLabel = computed(() =>
formatMessage(allCardsCollapsed.value ? messages.expandAll : messages.collapseAll),
)
function toggleAllCardsCollapsed() {
const collapsed = !allCardsCollapsed.value
const next = { ...cardCollapsedById.value }
for (const group of filteredGroups.value) {
next[group.id] = collapsed
}
cardCollapsedById.value = next
}
function dismissInfoBanner() {
flags.value.dismissedExternalProjectsInfo = true
saveFeatureFlags()
}
</script>
<template>
<template v-if="externalFiles.length > 0">
<Admonition
v-if="!flags.dismissedExternalProjectsInfo"
type="info"
class="mb-4"
:header="formatMessage(messages.infoBannerTitle)"
dismissible
@dismiss="dismissInfoBanner"
>
<IntlFormatted :message-id="messages.infoBannerDescription">
<template #link="{ children }">
<a class="text-link" target="_blank"> <component :is="() => children" /> </a>
</template>
</IntlFormatted>
<template #actions>
<div class="flex">
<ButtonStyled color="blue">
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
</ButtonStyled>
</div>
<Admonition
v-if="!flags.dismissedExternalProjectsInfo && !isModerator"
type="info"
class="mb-4"
:header="formatMessage(messages.infoBannerTitle)"
dismissible
@dismiss="dismissInfoBanner"
>
<IntlFormatted :message-id="messages.infoBannerDescription">
<template #link="{ children }">
<nuxt-link class="text-link" to="/news/article/modpack-permissions/" target="_blank">
<component :is="() => children" />
</nuxt-link>
</template>
</Admonition>
<Admonition
v-if="true"
type="success"
class="mb-4"
:header="formatMessage(messages.completedTitle)"
:body="formatMessage(messages.completedDescription)"
/>
<Admonition
v-if="true"
type="warning"
class="mb-4"
:header="formatMessage(messages.attentionNeededTitle)"
:body="formatMessage(messages.attentionNeededDescriptionDraft)"
/>
<Admonition
v-if="true"
type="critical"
class="mb-4"
:header="formatMessage(messages.failTitle)"
:body="formatMessage(messages.failDescription)"
/>
<div class="grid grid-cols-[1fr_auto] gap-2">
<StyledInput
v-model="searchQuery"
type="search"
:placeholder="
formatMessage(messages.searchPlaceholder, {
count: externalFiles.length,
})
"
:icon="SearchIcon"
input-class="h-[40px]"
/>
<div>
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<SortAscIcon
v-if="currentSortType === 'Oldest'"
class="size-5 flex-shrink-0 text-secondary"
/>
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast">{{ currentSortType }}</span>
</span>
</template>
</Combobox>
</IntlFormatted>
<template #actions>
<div class="flex">
<ButtonStyled color="blue">
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
</ButtonStyled>
</div>
</div>
<div class="mt-4 flex flex-col gap-3">
<ExternalProjectPermissionsCard title="FTB Library" />
</template>
</Admonition>
<template v-if="pending">
<div class="flex flex-col gap-3">
<div
v-for="i in 3"
:key="i"
class="h-[56px] w-full animate-pulse rounded-2xl bg-surface-3"
></div>
</div>
</template>
<template v-else-if="totalGroups > 0">
<template v-if="!isModerator">
<Admonition
v-if="
stats.pending === 0 &&
stats.noPermission === 0 &&
notAllowedCount === 0 &&
badProofCount === 0
"
type="success"
class="mb-4"
:header="formatMessage(messages.completedTitle)"
:body="formatMessage(messages.completedDescription)"
/>
<Admonition
v-else-if="notAllowedCount > 0"
type="critical"
class="mb-4"
:header="formatMessage(messages.failTitle)"
:body="formatMessage(messages.notAllowedDescription)"
/>
<Admonition
v-else-if="stats.noPermission > 0"
type="critical"
class="mb-4"
:header="formatMessage(messages.failTitle)"
:body="formatMessage(messages.failDescription)"
/>
<Admonition
v-else-if="badProofCount > 0"
type="critical"
class="mb-4"
:header="formatMessage(messages.badProofTitle)"
:body="formatMessage(messages.badProofDescription)"
/>
<Admonition
v-else-if="stats.pending > 0"
type="warning"
class="mb-4"
:header="formatMessage(messages.attentionNeededTitle)"
:body="
formatMessage(
projectIsApproved
? messages.attentionNeededDescriptionApproved
: messages.attentionNeededDescriptionDraft,
)
"
/>
</template>
<div class="flex flex-col gap-2">
<div class="grid grid-cols-[1fr_auto_auto] gap-2">
<StyledInput
v-model="searchQuery"
type="text"
autocomplete="off"
clearable
:placeholder="
formatMessage(messages.searchPlaceholder, {
count: totalGroups,
})
"
:icon="SearchIcon"
input-class="h-[40px]"
/>
<div>
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[220px] sm:flex-grow-0 [&>span]:h-[40px]"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<ArrowDownWideNarrowIcon
v-if="currentSortType === 'status'"
class="size-5 flex-shrink-0 text-secondary"
/>
<ArrowDown10Icon
v-else-if="currentSortType === 'most_files'"
class="size-5 flex-shrink-0 text-secondary"
/>
<ClockArrowDownIcon
v-else-if="currentSortType === 'recently_edited'"
class="size-5 flex-shrink-0 text-secondary"
/>
<ArrowDownWideNarrowIcon
v-else-if="currentSortType === 'rejected'"
class="size-5 flex-shrink-0 text-secondary"
/>
<span class="truncate text-contrast">{{ currentSortLabel }}</span>
</span>
</template>
</Combobox>
</div>
<ButtonStyled>
<button type="button" class="!h-[40px]" @click="toggleAllCardsCollapsed">
<UnfoldVerticalIcon
v-if="allCardsCollapsed"
class="size-5 flex-shrink-0 text-secondary"
/>
<FoldVerticalIcon v-else class="size-5 flex-shrink-0 text-secondary" />
{{ expandCollapseAllLabel }}
</button>
</ButtonStyled>
</div>
</div>
<div v-if="isModerator" class="mt-4 flex flex-wrap items-center gap-3">
<template v-if="noPermissionCount > 0">
<span class="flex items-center gap-1 text-red">
<XCircleIcon class="size-4 shrink-0" /> {{ noPermissionCount }} with no permission
</span>
<span class="text-surface-5"></span>
</template>
<template v-if="pendingCount > 0">
<span class="flex items-center gap-1 text-orange">
<PauseIcon class="size-4 shrink-0" /> {{ pendingCount }} need user action
</span>
<span class="text-surface-5"></span>
</template>
<span v-if="pendingApprovalCount > 0" class="flex items-center gap-1 text-contrast">
<ScaleIcon class="size-4 shrink-0" /> {{ pendingApprovalCount }} awaiting review
</span>
<span v-else class="flex items-center gap-1 text-secondary">
<ScaleIcon class="size-4 shrink-0" /> None awaiting review
</span>
</div>
<div class="mt-4 flex flex-col gap-3">
<TransitionGroup name="list">
<ExternalProjectPermissionsCard
v-for="group in filteredGroups"
:key="group.id"
:collapsed="getCardCollapsed(group)"
:project-id="project.id"
:group="group"
:is-moderator="isModerator"
@update:collapsed="setCardCollapsed(group.id, $event)"
/>
<EmptyState
v-if="filteredGroups.length === 0"
:heading="formatMessage(messages.noResults)"
type="no-search-result"
/>
</TransitionGroup>
</div>
</template>
<div v-else-if="attributionError">
<p v-if="attributionError" class="my-12 text-center text-red">
{{ attributionError }}
</p>
</div>
<template v-else>
<EmptyState
:heading="formatMessage(messages.emptyStateHeading)"
@@ -182,3 +608,18 @@ function dismissInfoBanner() {
/>
</template>
</template>
<style scoped>
.list-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.list-leave-to {
opacity: 0;
transform: translateY(10px);
}
.list-move {
transition: transform 200ms ease-in-out;
}
</style>
@@ -14,21 +14,22 @@
proceed-label="Delete"
@proceed="deleteVersion()"
/>
<Admonition
v-if="flags.modpackPermissionsPage && withheldVersions.length > 0"
type="warning"
v-if="withheldVersions.length > 0"
type="circle-warning"
class="mb-4"
:header="
formatMessage(messages.withheldVersionsWarningTitle, {
count: withheldVersions.length,
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
version_name:
withheldVersions.length === 1 ? withheldVersions[0].version_number : undefined,
})
"
:body="
formatMessage(messages.withheldVersionsWarningDescription, {
count: withheldVersions.length,
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
version_name:
withheldVersions.length === 1 ? withheldVersions[0].version_number : undefined,
})
"
>
@@ -40,7 +41,8 @@
project.slug ? project.slug : project.id
}/settings/permissions`"
>
{{ formatMessage(messages.withheldVersionsWarningResolve) }} <RightArrowIcon />
{{ formatMessage(commonProjectSettingsMessages.withheldVersionsWarningResolve) }}
<RightArrowIcon />
</nuxt-link>
</ButtonStyled>
</div>
@@ -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',
},
})
</script>
File diff suppressed because it is too large Load Diff
@@ -185,7 +185,7 @@ function mapExternalProject(
exceptions: project.exceptions,
proof: project.proof,
flame_project_id: project.flame_project_id,
files: project.linked_files,
files: project.linked_files ?? [],
}
}
@@ -149,27 +149,34 @@
<div class="card flex-card">
<h2>Members</h2>
<div class="details-list">
<template v-for="member in acceptedMembers" :key="member?.user.id">
<nuxt-link
class="details-list__item details-list__item--type-large"
:to="`/user/${member?.user?.username}`"
>
<Avatar :src="member?.user.avatar_url" circle />
<div class="rows">
<span class="flex items-center gap-1">
{{ member?.user?.username }}
<CrownIcon
v-if="member?.is_owner"
v-tooltip="'Organization owner'"
class="text-brand-orange"
/>
</span>
<span class="details-list__item__text--style-secondary">
{{ member?.role ? member.role : 'Member' }}
</span>
</div>
</nuxt-link>
</template>
<nuxt-link
v-for="member in acceptedMembers"
:key="`member-${member?.user?.id}`"
class="group flex w-fit items-center gap-2 leading-[1.2] text-primary"
:to="`/user/${member?.user?.username}`"
>
<Avatar
:src="member.user.avatar_url"
:alt="member.user.username"
size="32px"
circle
/>
<div class="flex flex-col">
<span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
<span class="min-w-0 overflow-hidden truncate font-normal text-contrast">{{
member.user.username
}}</span>
<CrownIcon
v-if="member.is_owner"
v-tooltip="'Organization owner'"
class="text-brand-orange"
/>
</span>
<span class="text-sm font-normal">
{{ member?.role ? member.role : 'Member' }}
</span>
</div>
</nuxt-link>
</div>
</div>
</div>
+1 -1
View File
@@ -284,11 +284,11 @@ import {
VersionIcon,
XCircleIcon,
} from '@modrinth/assets'
import { defineMessage } from '@modrinth/ui'
import {
AutoLink,
Avatar,
ButtonStyled,
defineMessage,
defineMessages,
formatReportItemType,
injectNotificationManager,
+5 -1
View File
@@ -133,7 +133,7 @@
<span class="flex items-center gap-2">
{{ user.username }}
<BadgeCheckIcon
v-if="isModrinthUser"
v-if="isOfficialAccount"
v-tooltip="formatMessage(messages.officialAccount)"
class="size-5 text-brand"
fill="var(--color-brand-highlight)"
@@ -810,6 +810,10 @@ const sortedOrgs = computed(() =>
)
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
@@ -0,0 +1,6 @@
import { attributionQuickReplies } from '@modrinth/moderation'
import { provideAttributionModeration } from '@modrinth/ui'
export function setupAttributionModerationProvider() {
provideAttributionModeration({ attributionQuickReplies })
}
@@ -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<Labrinth.Versions.v3.FileType | 'primary', s
signature: 'Signature',
}
export const fileTypeMessages: Record<
Labrinth.Versions.v3.FileType | 'primary',
MessageDescriptor
> = {
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<ManageVersionContextValue>('CreateProjectVersionModal')
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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<i64>\"\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<i64>",
"type_info": "Int8Array"
}
],
"parameters": {
"Left": [
"Int8Array",
"Int8"
]
},
"nullable": [
false,
false,
null,
true,
null
]
},
"hash": "48a83bc9de9bea73eac5c43e71f43fb8a1ac7eb67079d16e3de92f5ff6bf72b8"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n 'issues', (\n SELECT json_agg(\n to_jsonb(dri)\n || jsonb_build_object(\n -- TODO: replace with `json_array` in Postgres 16\n 'details', (\n SELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n )\n )\n FROM delphi_report_issues dri\n WHERE\n dri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json<FileReport>\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "data!: sqlx::types::Json<FileReport>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "8820a5985291c159c98371c9650092e3eba21c81e3b3386be779978aff30451a"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t'issues', (\n\t\t\t\t\tSELECT coalesce(json_agg(\n\t\t\t\t\t\tto_jsonb(dri)\n\t\t\t\t\t\t|| jsonb_build_object(\n\t\t\t\t\t\t\t-- TODO: replace with `json_array` in Postgres 16\n\t\t\t\t\t\t\t'details', (\n\t\t\t\t\t\t\t\tSELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n\t\t\t\t\t\t)\n\t\t\t\t\t), '[]'::json)\n\t\t\t\t\tFROM delphi_report_issues dri\n\t\t\t\t\tWHERE\n\t\t\t\t\t\tdri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json<FileReport>\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "data!: sqlx::types::Json<FileReport>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "fe4ff6ab40fe3dc3d474c0c23c9dba514c66ac30e573fc5f4e44ed7ff360d3d6"
}
+4 -1
View File
@@ -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
+34
View File
@@ -0,0 +1,34 @@
# Labrinth
Labrinth is the backend API service for Modrinth, written in Rust.
## Code style
- When writing `sqlx` queries, NEVER use `query` directly. Always prefer using the `query!`, `query_as!`, `query_scalar!` macros.
## Pre-PR Checks
When the user refers to "perform[ing] pre-PR checks", do the following:
- Run `cargo clippy -p labrinth --all-targets` — there must be ZERO warnings, otherwise CI will fail
- DO NOT run tests unless explicitly requested (they take a long time)
- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare -- --tests`
- NEVER run `cargo sqlx prepare --workspace`
## Testing
- Run `cargo test -p labrinth --all-targets` to test your changes — all tests must pass
## Local Services
- Read the root `docker-compose.yml` to see what running services are available while developing
- Use `docker exec` to access these services
### Clickhouse
- Access: `docker exec labrinth-clickhouse clickhouse-client`
- Database: `staging_ariadne`
### Postgres
- Access: `docker exec labrinth-postgres psql -U labrinth -d labrinth -c "<query>"`
@@ -0,0 +1,33 @@
create table file_scans (
file_id bigint primary key references files(id),
-- if a file..
-- - does not have a row
-- -> was created before attributions system
-- - has a row, but `attributions_scanned_at = null`
-- -> still needs to be scanned
-- - has a row, and `attributions_scanned_at` is not null
-- -> attributions have been scanned
attributions_scanned_at timestamptz
);
create table project_attribution_groups (
id bigint primary key,
project_id bigint not null references mods(id),
flame_project jsonb,
attribution jsonb,
attributed_at timestamptz,
attributed_by bigint references users(id)
);
create index on project_attribution_groups (project_id);
create table project_attribution_files (
group_id bigint not null references project_attribution_groups(id),
name text not null,
sha1 bytea not null
);
create table override_file_sources (
sha1 bytea not null,
file_id bigint not null references files(id),
primary key (sha1, file_id)
);
@@ -0,0 +1,19 @@
alter table file_scans
drop constraint file_scans_file_id_fkey,
add constraint file_scans_file_id_fkey
foreign key (file_id) references files(id) on delete cascade;
alter table project_attribution_groups
drop constraint project_attribution_groups_project_id_fkey,
add constraint project_attribution_groups_project_id_fkey
foreign key (project_id) references mods(id) on delete cascade;
alter table project_attribution_files
drop constraint project_attribution_files_group_id_fkey,
add constraint project_attribution_files_group_id_fkey
foreign key (group_id) references project_attribution_groups(id) on delete cascade;
alter table override_file_sources
drop constraint override_file_sources_file_id_fkey,
add constraint override_file_sources_file_id_fkey
foreign key (file_id) references files(id) on delete cascade;
@@ -0,0 +1,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;
@@ -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;
+72 -2
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>()
})
.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)
+8
View File
@@ -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<dyn SearchBackend>,
file_host: web::Data<dyn FileHost>,
kafka_client: web::Data<crate::util::kafka::KafkaClientState>,
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
}
+11 -6
View File
@@ -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",
@@ -6,6 +6,7 @@ use super::{DBUser, ids::*};
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::database::{PgTransaction, models};
use crate::file_hosting::FileHost;
use crate::models::exp;
use crate::models::ids::ProjectId;
use crate::models::projects::{
@@ -187,6 +188,8 @@ impl ProjectBuilder {
pub async fn insert(
self,
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
file_host: &dyn FileHost,
http: &reqwest::Client,
) -> Result<DBProjectId, DatabaseError> {
let project_struct = DBProject {
@@ -235,7 +238,7 @@ impl ProjectBuilder {
for mut version in self.initial_versions {
version.project_id = self.project_id;
version.insert(&mut *transaction, http).await?;
version.insert(transaction, redis, file_host, http).await?;
}
LinkUrl::insert_many_projects(
@@ -6,8 +6,11 @@ use crate::database::models::loader_fields::{
QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField,
};
use crate::database::redis::RedisPool;
use crate::file_hosting::FileHost;
use crate::models::exp;
use crate::models::projects::{FileType, VersionStatus};
use crate::queue::file_scan::scan_file;
use crate::routes::internal::delphi::DelphiRunParameters;
use chrono::{DateTime, Utc};
use dashmap::{DashMap, DashSet};
@@ -17,10 +20,31 @@ use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::HashMap;
use std::iter;
use tracing::error;
pub const VERSIONS_NAMESPACE: &str = "versions";
const VERSION_FILES_NAMESPACE: &str = "versions_files";
pub async fn cleanup_empty_attribution_groups(
transaction: &mut PgTransaction<'_>,
) -> Result<(), DatabaseError> {
sqlx::query!(
"
DELETE FROM project_attribution_groups g
WHERE NOT EXISTS (
SELECT 1
FROM project_attribution_files paf
INNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1
WHERE paf.group_id = g.id
)
",
)
.execute(&mut *transaction)
.await?;
Ok(())
}
#[derive(Clone)]
pub struct VersionBuilder {
pub version_id: DBVersionId,
@@ -134,7 +158,10 @@ impl VersionFileBuilder {
pub async fn insert(
self,
version_id: DBVersionId,
project_id: DBProjectId,
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
file_host: &dyn FileHost,
http: &reqwest::Client,
) -> Result<DBFileId, DatabaseError> {
let file_id = generate_file_id(&mut *transaction).await?;
@@ -169,6 +196,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<DBVersionId, DatabaseError> {
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<DBVersionId, Vec<DependencyQueryResult>> = 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<DependencyQueryResult>>, 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<Vec<DBFile>, DatabaseError>
where
E: crate::database::Executor<'a, Database = sqlx::Postgres> + Copy,
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
let val = redis.get_cached_keys(
VERSION_FILES_NAMESPACE,
@@ -977,10 +1048,12 @@ pub struct VersionQueryResult {
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct DependencyQueryResult {
pub id: i32,
pub project_id: Option<DBProjectId>,
pub version_id: Option<DBVersionId>,
pub file_name: Option<String>,
pub dependency_type: String,
pub attribution: Option<crate::models::projects::DependencyAttribution>,
}
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
+11 -3
View File
@@ -29,9 +29,7 @@ impl FileHost for MockHost {
file_publicity: FileHostPublicity,
file_bytes: Bytes,
) -> Result<UploadFileData, FileHostingError> {
let file_name = urlencoding::decode(file_name)
.map_err(|_| FileHostingError::InvalidFilename)?;
let path = get_file_path(&file_name, file_publicity);
let path = get_file_path(file_name, file_publicity);
std::fs::create_dir_all(
path.parent().ok_or(FileHostingError::InvalidFilename)?,
)?;
@@ -72,6 +70,16 @@ impl FileHost for MockHost {
file_name: file_name.to_string(),
})
}
async fn read_file(
&self,
file_name: &str,
file_publicity: FileHostPublicity,
) -> Result<Bytes, FileHostingError> {
let path = get_file_path(file_name, file_publicity);
let data = std::fs::read(&path)?;
Ok(Bytes::from(data))
}
}
fn get_file_path(
+23 -1
View File
@@ -45,7 +45,11 @@ pub enum FileHostPublicity {
}
#[async_trait]
pub trait FileHost {
pub trait FileHost: Send + Sync {
/// Uploads a file at the exact storage key provided.
///
/// Callers must URL-decode keys derived from public URLs before passing
/// them here, and URL-encode this key before exposing it in a public URL.
async fn upload_file(
&self,
content_type: &str,
@@ -54,17 +58,35 @@ pub trait FileHost {
file_bytes: Bytes,
) -> Result<UploadFileData, FileHostingError>;
/// Returns a private URL for the exact storage key provided.
///
/// Callers must URL-decode keys derived from public URLs before passing
/// them here.
async fn get_url_for_private_file(
&self,
file_name: &str,
expiry_secs: u32,
) -> Result<String, FileHostingError>;
/// Deletes the file at the exact storage key provided.
///
/// Callers must URL-decode keys derived from public URLs before passing
/// them here.
async fn delete_file(
&self,
file_name: &str,
file_publicity: FileHostPublicity,
) -> Result<DeleteFileData, FileHostingError>;
/// Reads the file at the exact storage key provided.
///
/// Callers must URL-decode keys derived from public URLs before passing
/// them here.
async fn read_file(
&self,
file_name: &str,
file_publicity: FileHostPublicity,
) -> Result<Bytes, FileHostingError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+24
View File
@@ -169,4 +169,28 @@ impl FileHost for S3Host {
file_name: file_name.to_string(),
})
}
async fn read_file(
&self,
file_name: &str,
file_publicity: FileHostPublicity,
) -> Result<Bytes, FileHostingError> {
let bucket = self.get_bucket(file_publicity);
let response = bucket
.client
.get_object()
.bucket(bucket.name.as_str())
.key(file_name)
.send()
.await
.map_err(|e| s3_error("reading file", e))?;
Ok(response
.body
.collect()
.await
.map_err(|e| s3_error("reading file body", e))?
.into_bytes())
}
}
+3 -3
View File
@@ -58,7 +58,7 @@ pub struct LabrinthConfig {
pub ro_pool: ReadOnlyPgPool,
pub redis_pool: RedisPool,
pub clickhouse: Client,
pub file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
pub file_host: web::Data<dyn file_hosting::FileHost>,
pub scheduler: Arc<scheduler::Scheduler>,
pub ip_salt: Pepper,
pub search_state: web::Data<search::SearchState>,
@@ -85,7 +85,7 @@ pub fn app_setup(
redis_pool: RedisPool,
search_backend: actix_web::web::Data<dyn search::SearchBackend>,
clickhouse: &mut Client,
file_host: Arc<dyn file_hosting::FileHost + Send + Sync>,
file_host: web::Data<dyn file_hosting::FileHost>,
stripe_client: stripe::Client,
anrok_client: anrok::Client,
email_queue: EmailQueue,
@@ -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(),
))
+33 -38
View File
@@ -2,14 +2,14 @@
use actix_web::dev::Service;
use actix_web::middleware::from_fn;
use actix_web::{App, HttpServer};
use actix_web::{App, HttpServer, web};
use actix_web_prom::PrometheusMetricsBuilder;
use clap::Parser;
use labrinth::background_task::BackgroundTask;
use labrinth::database::redis::RedisPool;
use labrinth::env::ENV;
use labrinth::file_hosting::{FileHostKind, S3BucketConfig, S3Host};
use labrinth::file_hosting::{FileHost, FileHostKind, S3BucketConfig, S3Host};
use labrinth::queue::email::EmailQueue;
use labrinth::search;
use labrinth::util::anrok;
@@ -111,44 +111,38 @@ async fn app() -> std::io::Result<()> {
let redis_pool = RedisPool::new("");
let storage_backend = ENV.STORAGE_BACKEND;
let file_host: Arc<dyn file_hosting::FileHost + Send + Sync> =
match storage_backend {
FileHostKind::S3 => {
let not_empty = |v: &str| -> String {
assert!(!v.is_empty(), "S3 env var is empty");
v.to_string()
};
let file_host: Arc<dyn FileHost> = match storage_backend {
FileHostKind::S3 => {
let not_empty = |v: &str| -> String {
assert!(!v.is_empty(), "S3 env var is empty");
v.to_string()
};
Arc::new(
S3Host::new(
S3BucketConfig {
name: not_empty(&ENV.S3_PUBLIC_BUCKET_NAME),
uses_path_style: ENV
.S3_PUBLIC_USES_PATH_STYLE_BUCKET,
region: not_empty(&ENV.S3_PUBLIC_REGION),
url: not_empty(&ENV.S3_PUBLIC_URL),
access_token: not_empty(
&ENV.S3_PUBLIC_ACCESS_TOKEN,
),
secret: not_empty(&ENV.S3_PUBLIC_SECRET),
},
S3BucketConfig {
name: not_empty(&ENV.S3_PRIVATE_BUCKET_NAME),
uses_path_style: ENV
.S3_PRIVATE_USES_PATH_STYLE_BUCKET,
region: not_empty(&ENV.S3_PRIVATE_REGION),
url: not_empty(&ENV.S3_PRIVATE_URL),
access_token: not_empty(
&ENV.S3_PRIVATE_ACCESS_TOKEN,
),
secret: not_empty(&ENV.S3_PRIVATE_SECRET),
},
)
.unwrap(),
Arc::new(
S3Host::new(
S3BucketConfig {
name: not_empty(&ENV.S3_PUBLIC_BUCKET_NAME),
uses_path_style: ENV.S3_PUBLIC_USES_PATH_STYLE_BUCKET,
region: not_empty(&ENV.S3_PUBLIC_REGION),
url: not_empty(&ENV.S3_PUBLIC_URL),
access_token: not_empty(&ENV.S3_PUBLIC_ACCESS_TOKEN),
secret: not_empty(&ENV.S3_PUBLIC_SECRET),
},
S3BucketConfig {
name: not_empty(&ENV.S3_PRIVATE_BUCKET_NAME),
uses_path_style: ENV.S3_PRIVATE_USES_PATH_STYLE_BUCKET,
region: not_empty(&ENV.S3_PRIVATE_REGION),
url: not_empty(&ENV.S3_PRIVATE_URL),
access_token: not_empty(&ENV.S3_PRIVATE_ACCESS_TOKEN),
secret: not_empty(&ENV.S3_PRIVATE_SECRET),
},
)
}
FileHostKind::Local => Arc::new(file_hosting::MockHost::new()),
};
.unwrap(),
)
}
FileHostKind::Local => Arc::new(file_hosting::MockHost::new()),
};
let file_host = web::Data::<dyn FileHost>::from(file_host);
info!("Initializing clickhouse connection");
let mut clickhouse = clickhouse::init_client().await.unwrap();
@@ -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,
+1
View File
@@ -1,5 +1,6 @@
use ariadne::ids::base62_id;
base62_id!(AttributionGroupId);
base62_id!(ChargeId);
base62_id!(CampaignDonationId);
base62_id!(CollectionId);
+110
View File
@@ -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<OverrideSource>,
}
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum OverrideSource {
Flame {
id: u32,
title: String,
url: String,
icon_url: String,
},
Unknown,
}
#[derive(
Debug, Serialize, Deserialize, Clone, PartialEq, Eq, utoipa::ToSchema,
)]
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<Url>,
},
}
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AttributionModerationStatusKind {
NotAllowed,
Approved,
BadProof,
}
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct AttributionModerationStatus {
#[serde(flatten)]
pub kind: AttributionModerationStatusKind,
#[serde(default)]
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub moderated_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub moderated_by: Option<UserId>,
}
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct AttributionResolution {
#[serde(flatten)]
pub kind: AttributionResolutionKind,
#[serde(default)]
pub moderation_status: Option<AttributionModerationStatus>,
#[serde(default)]
pub updated_by_moderator: bool,
pub notes: String,
pub image_urls: Vec<Url>,
}
/// A specific version of a project
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct Version {
@@ -681,6 +774,9 @@ pub struct Version {
/// A list of files available for download for this version.
pub files: Vec<VersionFile>,
/// Files in this version that contain override files not yet attributed.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files_missing_attribution: Vec<MissingAttributionFile>,
/// A list of projects that this version depends on.
pub dependencies: Vec<Dependency>,
@@ -757,6 +853,7 @@ impl From<VersionQueryResult> 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<VersionQueryResult> 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<String>,
/// The type of the dependency
pub dependency_type: DependencyType,
#[serde(skip_serializing_if = "Option::is_none")]
pub attribution: Option<DependencyAttribution>,
}
#[derive(
Serialize, Deserialize, Clone, Debug, PartialEq, Eq, utoipa::ToSchema,
)]
pub struct DependencyAttribution {
#[serde(skip_serializing_if = "Option::is_none")]
pub flame_project: Option<FlameProject>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<AttributionResolutionKind>,
}
#[derive(
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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;
+11 -4
View File
@@ -570,7 +570,7 @@ impl AutomatedModerationQueue {
Vec::new()
} else {
let res = client
.post(format!("{}v1/mods", ENV.FLAME_ANVIL_URL))
.post(format!("{}/v1/mods", ENV.FLAME_ANVIL_URL))
.json(&serde_json::json!({
"modIds": flame_files.iter().map(|x| x.1).collect::<Vec<_>>()
}))
@@ -579,7 +579,7 @@ impl AutomatedModerationQueue {
.text()
.await?;
serde_json::from_str::<FlameResponse<Vec<FlameProject>>>(&res)?.data
serde_json::from_str::<FlameResponse<Vec<FlameProjectResponse>>>(&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)]

Some files were not shown because too many files have changed in this diff Show More