Compare commits

..
Author SHA1 Message Date
aecsocket 0cac5648c5 fix 2026-07-04 13:37:35 +01:00
aecsocket f0294e9485 Merge branch 'main' into fetch/nicer-docs 2026-07-04 13:35:21 +01:00
François-X. T. b50738d5b0 bro what 2026-07-03 18:17:55 -04:00
François-X. T. 7e7d0192c8 test 2026-07-03 18:06:25 -04:00
aecsocketandGitHub b26d048a63 Remove AutoMod code (#6604)
* 🦀 automod is gone 🦀

* prepare
2026-07-03 21:32:11 +00:00
François-X. T. 25089c5252 remove unused tests & script 2026-07-03 17:23:57 -04:00
François-X. T. c36a004ef0 increase compiler spawned thread stack size 2026-07-03 17:09:14 -04:00
François-X. T. 4c114e1df7 improve examples 2026-07-03 16:46:50 -04:00
aecsocket 01f4019db0 fix test 2026-07-03 21:29:48 +01:00
aecsocket 5543f9db7e fix 2026-07-03 20:59:03 +01:00
aecsocket 9ecd757ddd Merge branch 'main' into fetch/nicer-docs 2026-07-03 20:36:44 +01:00
François-X. T. a4615bfd7d chore: tweak sources titles 2026-07-03 15:28:25 -04:00
aecsocket d41b71293b fixes 2026-07-03 20:25:23 +01:00
François-X. T. 78a5edd069 chore: remove moderation route from v2, remove ingest & webhooks from v3 spec 2026-07-03 15:22:13 -04:00
aecsocket d28f791f17 request body docs 2026-07-03 20:16:23 +01:00
aecsocket 4b4a3fa4c1 more utoipa param fixes 2026-07-03 19:52:01 +01:00
François-Xavier TalbotandGitHub d5be2bd739 fix(labrinth): include SharedInstanceInvite notifications for ws (#6603)
* fix(labrinth): include SharedInstanceInvite notifications for ws

* chore: fmt
2026-07-03 17:06:58 +00:00
Prospector 0dac5684af changelog 2026-07-03 08:16:29 -07:00
coolbotandGitHub ca0b8d5ca4 add excessive languages warning nag (#6587) 2026-07-03 12:12:07 +00:00
François-X. T. ad65cd6505 better hash examples, move openapi stuff to openapi.rs 2026-07-02 18:42:11 -04:00
François-X. T. 3bea5ed19a improve formatting, examples 2026-07-02 18:00:51 -04:00
François-X. T. f7ac372e76 improve version intros 2026-07-02 17:46:48 -04:00
François-X. T. f9bf83d299 consistency 2026-07-02 17:03:25 -04:00
coolbotandGitHub b6651efe52 Moderation Fix: Handle more license cases (#6590)
Apply correct behavior in edge cases
2026-07-02 17:26:10 +00:00
François-X. T. d4c6466a1d remove utoipa-actix-web 2026-07-02 12:58:48 -04:00
aecsocket bd17856bde Fix path parameter docs in routes 2026-07-02 17:56:24 +01:00
aecsocketandGitHub eb28121c0a fix: scan logic file whitelist + rescan route (#6585)
* explicitly only scan jar/zip/disabled files for attributions

* add route to force scan file

* prepare
2026-07-02 16:33:16 +00:00
SychicandGitHub a5ebc85356 refactor(labrinth): combine logic for external notification endpoints (#6578)
* refactor(labrinth): combine logic for external notification endpoints

also introduce new notification that doesn't send an email

* style(labrinth): derive default

* feat(labrinth): return more detailed failure information for external
notifications
2026-07-02 15:44:47 +00:00
Prospector c530671be1 fix collection widget on mobile 2026-07-01 18:09:12 -07:00
Prospector bb6d8a6fc4 changelog 2026-07-01 17:44:29 -07:00
ProspectorandGitHub 256e8f946a chore: pride month fundraiser conclusion (#6588)
* pride end blog post

* Fix drago link

* prepr oops
2026-07-01 17:43:44 -07:00
François-X. T. 7f8c7f8b39 fix: restore labrinth docs routes 2026-07-01 17:32:02 -04:00
François-X. T. b0e9c560ba response schemas 2026-07-01 16:28:54 -04:00
François-X. T. dd3f0ece6d chore: fix some of the routes rendering with missing / 2026-07-01 16:05:00 -04:00
François-X. T. d0e8edbc18 fix tombi 2026-07-01 15:48:36 -04:00
François-X. T. 4f67775261 feat: docs 2026-07-01 15:41:34 -04:00
Calum H.andGitHub 7e5590b8b9 fix: improve observability during fetch failure (#6555)
* fix: improve observability during fetch failure

+ remove analytics log spam

* fix: rev comments
2026-07-01 15:58:20 +00:00
SychicandGitHub d20f05fb04 feat(labrinth): instance share notification (#6581) 2026-07-01 14:21:56 +00:00
Truman GaoandGitHub a00a7971d1 feat: add tooltip for unknown dependents (#6572) 2026-07-01 05:21:42 +00:00
aecsocketandGitHub 26e98b7155 fix: change Tiltify campaign errors to quieter log level v2 (#6576)
import
2026-06-30 21:36:47 +02:00
aecsocketandGitHub 77b06f679e fix: change Tiltify campaign errors to quieter log level (#6575) 2026-06-30 21:34:37 +02:00
Truman GaoandGitHub 50e9472dd9 fix: cannot set owner for creating projects (#6573) 2026-06-30 17:00:10 +00:00
ThatGravyBoatandGitHub b569354793 fix: packs not being validated correctly (#6533) 2026-06-30 14:02:58 +00:00
Michael H. b667b3ae9e fix: remove sccache s3 2026-06-30 11:41:08 +02:00
coolbotandGitHub 16de6182e4 Add nag for custom license info. (#6564)
* add nag for custom license info

* preppr
2026-06-30 02:25:13 +00:00
Calum H.andGitHub 364fed52dd feat: changelog (#6563) 2026-06-30 01:57:11 +02:00
Calum H.andGitHub 0fe695721a fix: try fix app freezing on instance page load (#6562)
* fix: app freezing fix

* fix: fmt
2026-06-30 01:55:55 +02:00
Calum H.andGitHub 692f22b749 feat: changelog 0.15.3 (#6560)
feat: changlog 0.15.3
2026-06-30 00:54:16 +02:00
Calum H.andGitHub ed7792097d fix: better handling of larger instances for ExportModal (#6559) 2026-06-30 00:51:25 +02:00
Prospector c56ce6c9ee changelog 2026-06-29 13:45:04 -07:00
Calum H.andGitHub 91bc58b8ee fix: cleanup stale db entry on rename instance file (#6549)
fix: race on renaming instance file
2026-06-29 17:36:46 +00:00
Truman GaoandGitHub 38d757b66a fix: analytics colour pick is not excluding unknown (#6552)
* fix: analytics colour picking not excluding unknown

* feat: improve colours, better accessibility
2026-06-29 17:17:03 +00:00
Calum H.andGitHub 862b54f00d refactor: instance export modal (#6543)
* refactor: export modal w the design system

* fix: lint

* fix: rev

* fix: lint

* fix: qa
2026-06-29 17:01:00 +00:00
Calum H.andGitHub c2f4835fe7 fix: spacing + friends (#6550) 2026-06-29 17:00:59 +00:00
ThatGravyBoatandGitHub b97c191d75 fix(docs): create version endpoint missing file_types field (#6551) 2026-06-29 16:22:08 +00:00
206 changed files with 6861 additions and 2465 deletions
-7
View File
@@ -76,13 +76,6 @@ jobs:
env:
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
+1 -7
View File
@@ -78,14 +78,8 @@ jobs:
GIT_HASH: ${{ github.sha }}
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
RUST_MIN_STACK: '16777216'
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-7
View File
@@ -81,13 +81,6 @@ jobs:
# blacksmith runner)
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
steps:
Generated
+147 -64
View File
@@ -59,9 +59,9 @@ dependencies = [
[[package]]
name = "actix-http"
version = "3.11.2"
version = "3.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49"
checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2"
dependencies = [
"actix-codec",
"actix-rt",
@@ -75,7 +75,7 @@ dependencies = [
"derive_more 2.1.1",
"encoding_rs",
"flate2",
"foldhash 0.1.5",
"foldhash 0.2.0",
"futures-core",
"h2 0.3.27",
"http 0.2.12",
@@ -87,8 +87,8 @@ dependencies = [
"mime",
"percent-encoding",
"pin-project-lite",
"rand 0.9.2",
"sha1",
"rand 0.10.1",
"sha1 0.11.0",
"smallvec",
"tokio",
"tokio-util",
@@ -146,9 +146,9 @@ dependencies = [
[[package]]
name = "actix-router"
version = "0.5.3"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8"
checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7"
dependencies = [
"bytestring",
"cfg-if",
@@ -209,9 +209,9 @@ dependencies = [
[[package]]
name = "actix-web"
version = "4.11.0"
version = "4.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a597b77b5c6d6a1e1097fddde329a83665e25c5437c696a3a9a4aa514a614dea"
checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4"
dependencies = [
"actix-codec",
"actix-http",
@@ -228,7 +228,7 @@ dependencies = [
"cookie 0.16.2",
"derive_more 2.1.1",
"encoding_rs",
"foldhash 0.1.5",
"foldhash 0.2.0",
"futures-core",
"futures-util",
"impl-more",
@@ -244,7 +244,7 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"smallvec",
"socket2 0.5.10",
"socket2 0.6.1",
"time",
"tracing",
"url",
@@ -1127,7 +1127,7 @@ dependencies = [
"http-body-util",
"md-5",
"pin-project-lite",
"sha1",
"sha1 0.10.6",
"sha2",
"tracing",
]
@@ -1499,7 +1499,7 @@ version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -1511,6 +1511,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -2224,6 +2233,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "const-random"
version = "0.1.18"
@@ -2424,7 +2439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d"
dependencies = [
"crc",
"digest",
"digest 0.10.7",
"rustversion",
"spin 0.10.0",
]
@@ -2515,6 +2530,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.29.6"
@@ -2791,7 +2815,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"const-oid 0.9.6",
"pem-rfc7468",
"zeroize",
]
@@ -2904,12 +2928,23 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"block-buffer 0.10.4",
"const-oid 0.9.6",
"crypto-common 0.1.6",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid 0.10.2",
"crypto-common 0.2.2",
]
[[package]]
name = "directories"
version = "6.0.0"
@@ -3127,7 +3162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature",
@@ -3151,7 +3186,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"digest 0.10.7",
"ff",
"generic-array",
"group",
@@ -4402,7 +4437,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -4532,6 +4567,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "0.14.32"
@@ -4893,9 +4937,9 @@ checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
[[package]]
name = "impl-more"
version = "0.1.9"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870"
[[package]]
name = "indenter"
@@ -5390,11 +5434,12 @@ dependencies = [
"rust_iso3166",
"rustls 0.23.32",
"rusty-money",
"scalar_api_reference",
"sentry",
"serde",
"serde_json",
"serde_with",
"sha1",
"sha1 0.10.6",
"sha2",
"spdx",
"sqlx",
@@ -5411,8 +5456,6 @@ dependencies = [
"url",
"urlencoding",
"utoipa",
"utoipa-actix-web",
"utoipa-scalar",
"uuid 1.23.3",
"validator",
"webauthn-rs",
@@ -5822,7 +5865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
"digest 0.10.7",
]
[[package]]
@@ -8494,8 +8537,8 @@ version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b"
dependencies = [
"const-oid",
"digest",
"const-oid 0.9.6",
"digest 0.10.7",
"num-bigint-dig",
"num-integer",
"num-traits",
@@ -8508,6 +8551,40 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rust-embed"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.106",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"sha2",
"walkdir",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
@@ -8827,6 +8904,18 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "scalar_api_reference"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d67c85edd56c41f364603eff688a330f01dee99bdfc84c005d3bc3d0721d6b2"
dependencies = [
"actix-web",
"rust-embed",
"serde",
"serde_json",
]
[[package]]
name = "scc"
version = "2.4.0"
@@ -9252,15 +9341,15 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.145"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
"zmij",
]
[[package]]
@@ -9455,7 +9544,18 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@@ -9472,7 +9572,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
@@ -9505,7 +9605,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"digest 0.10.7",
"rand_core 0.6.4",
]
@@ -9782,7 +9882,7 @@ dependencies = [
"bytes",
"chrono",
"crc",
"digest",
"digest 0.10.7",
"dotenvy",
"either",
"futures-channel",
@@ -9803,7 +9903,7 @@ dependencies = [
"rsa",
"rust_decimal",
"serde",
"sha1",
"sha1 0.10.6",
"sha2",
"smallvec",
"sqlx-core",
@@ -11290,7 +11390,7 @@ dependencies = [
"constant_time_eq",
"hmac",
"rand 0.9.2",
"sha1",
"sha1 0.10.6",
"sha2",
]
@@ -11505,7 +11605,7 @@ dependencies = [
"rand 0.9.2",
"rustls 0.23.32",
"rustls-pki-types",
"sha1",
"sha1 0.10.6",
"thiserror 2.0.17",
"utf-8",
]
@@ -11524,9 +11624,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.19.0"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
@@ -11757,17 +11857,6 @@ dependencies = [
"utoipa-gen",
]
[[package]]
name = "utoipa-actix-web"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7eda9c23c05af0fb812f6a177514047331dac4851a2c8e9c4b895d6d826967f"
dependencies = [
"actix-service",
"actix-web",
"utoipa",
]
[[package]]
name = "utoipa-gen"
version = "5.4.0"
@@ -11782,18 +11871,6 @@ dependencies = [
"uuid 1.23.3",
]
[[package]]
name = "utoipa-scalar"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719"
dependencies = [
"actix-web",
"serde",
"serde_json",
"utoipa",
]
[[package]]
name = "uuid"
version = "0.8.2"
@@ -13450,6 +13527,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.2"
+1 -2
View File
@@ -158,6 +158,7 @@ rust-s3 = { version = "0.37.0", default-features = false, features = [
] }
rustls = "0.23.32"
rusty-money = "0.4.1"
scalar_api_reference = { version = "0.2.2", default-features = false }
secrecy = "0.10.3"
sentry = { version = "0.45.0", default-features = false, features = [
"backtrace",
@@ -217,8 +218,6 @@ typed-path = "0.12.0"
url = "2.5.7"
urlencoding = "2.1.3"
utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "decimal"] }
utoipa-actix-web = { version = "0.1.2" }
utoipa-scalar = { version = "0.3.0", default-features = false }
uuid = "1.18.1"
validator = "0.20.0"
webauthn-rs = "0.5.5"
@@ -1,28 +1,32 @@
<script setup>
import { WrenchIcon, XIcon } from '@modrinth/assets'
import { XIcon } from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
FileTreeSelect,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
import { readDir, stat } from '@tauri-apps/plugin-fs'
import { ref } from 'vue'
import { PackageIcon, VersionIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
import { PackageIcon } from '@/assets/icons'
import {
export_instance_mrpack,
get_full_path,
get_pack_export_candidates,
} from '@/helpers/instance'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: { id: 'app.export-modal.header', defaultMessage: 'Export modpack' },
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack Name' },
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack name' },
modpackNamePlaceholder: {
id: 'app.export-modal.modpack-name-placeholder',
defaultMessage: 'Modpack name',
@@ -39,15 +43,7 @@ const messages = defineMessages({
id: 'app.export-modal.description-placeholder',
defaultMessage: 'Enter modpack description...',
},
selectFilesLabel: {
id: 'app.export-modal.select-files-label',
defaultMessage: 'Configure which files are included in this export',
},
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
includeFile: {
id: 'app.export-modal.include-file-accessibility-label',
defaultMessage: 'Include "{file}"?',
},
})
const props = defineProps({
@@ -59,8 +55,9 @@ const props = defineProps({
defineExpose({
show: () => {
resetExportState()
exportModal.value.show()
initFiles()
void initFiles().catch(handleError)
},
})
@@ -69,62 +66,33 @@ const nameInput = ref(props.instance.name)
const exportDescription = ref('')
const versionInput = ref('1.0.0')
const files = ref([])
const folders = ref([])
const selectedFilePaths = ref([])
const fileTreeKey = ref(0)
const filesLoadId = ref(0)
const instanceRoot = ref('')
const loadedDirectories = ref(new Set())
const initFiles = async () => {
const newFolders = new Map()
const sep = '/'
files.value = []
await get_pack_export_candidates(props.instance.id).then((filePaths) =>
filePaths
.map((folder) => ({
path: folder,
name: folder.split(sep).pop(),
selected:
folder.startsWith('mods') ||
folder.startsWith('datapacks') ||
folder.startsWith('resourcepacks') ||
folder.startsWith('shaderpacks') ||
folder.startsWith('config'),
disabled:
folder === 'profile.json' ||
folder.startsWith('modrinth_logs') ||
folder.startsWith('.fabric') ||
folder.startsWith('__MACOSX'),
}))
.forEach((pathData) => {
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
if (parent !== '') {
if (newFolders.has(parent)) {
newFolders.get(parent).push(pathData)
} else {
newFolders.set(parent, [pathData])
}
} else {
files.value.push(pathData)
}
}),
)
folders.value = [...newFolders.entries()].map(([name, value]) => [
{
name,
showingMore: false,
},
value,
async function initFiles() {
const loadId = ++filesLoadId.value
const [filePaths, root] = await Promise.all([
get_pack_export_candidates(props.instance.id),
get_full_path(props.instance.id),
])
if (loadId !== filesLoadId.value) return
instanceRoot.value = root
const exportCandidates = await Promise.all(
filePaths.map((path) => buildExportCandidateItem(root, path)),
)
if (loadId !== filesLoadId.value) return
files.value = exportCandidates
selectedFilePaths.value = files.value
.filter((file) => !file.disabled && isDefaultSelectedExportCandidate(file.path))
.map((file) => file.path)
}
await initFiles()
const exportPack = async () => {
const filesToExport = files.value.filter((file) => file.selected).map((file) => file.path)
folders.value.forEach((args) => {
args[1].forEach((child) => {
if (child.selected) {
filesToExport.push(child.path)
}
})
})
const outputPath = await save({
defaultPath: `${nameInput.value} ${versionInput.value}.mrpack`,
filters: [
@@ -139,7 +107,7 @@ const exportPack = async () => {
export_instance_mrpack(
props.instance.id,
outputPath,
filesToExport,
selectedFilePaths.value,
versionInput.value,
exportDescription.value,
nameInput.value,
@@ -147,94 +115,176 @@ const exportPack = async () => {
exportModal.value.hide()
}
}
function resetExportState() {
nameInput.value = props.instance.name
exportDescription.value = ''
versionInput.value = '1.0.0'
files.value = []
selectedFilePaths.value = []
fileTreeKey.value += 1
instanceRoot.value = ''
loadedDirectories.value = new Set()
}
async function loadExportDirectory(path) {
if (!path || !instanceRoot.value || loadedDirectories.value.has(path)) return
const loadId = filesLoadId.value
loadedDirectories.value.add(path)
try {
const entries = await readDir(`${instanceRoot.value}/${path}`)
const childItems = await Promise.all(
entries.map((entry) => buildExportDirectoryChildItem(instanceRoot.value, path, entry)),
)
if (loadId !== filesLoadId.value) return
appendExportItems(childItems)
} catch {
loadedDirectories.value.delete(path)
}
}
async function buildExportCandidateItem(instanceRoot, path) {
try {
const entries = await readDir(`${instanceRoot}/${path}`)
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'directory',
disabled: isExportCandidateDisabled(path),
modified: metadata.modified,
count: entries.length,
}
} catch {
return buildExportFileItem(instanceRoot, path)
}
}
async function buildExportDirectoryChildItem(instanceRoot, parentPath, entry) {
const path = `${parentPath}/${entry.name}`
if (entry.isDirectory) {
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'directory',
disabled: isExportCandidateDisabled(path),
modified: metadata.modified,
}
}
return buildExportFileItem(instanceRoot, path)
}
async function buildExportFileItem(instanceRoot, path) {
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'file',
disabled: isExportCandidateDisabled(path),
size: metadata.size,
modified: metadata.modified,
}
}
function appendExportItems(items) {
const nextFiles = new Map(files.value.map((file) => [normalizeExportPath(file.path), file]))
for (const item of items) {
nextFiles.set(normalizeExportPath(item.path), item)
}
files.value = [...nextFiles.values()]
}
async function getExportCandidateMetadata(instanceRoot, path) {
try {
const metadata = await stat(`${instanceRoot}/${path}`)
return {
size: metadata.size,
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : undefined,
}
} catch {
return {}
}
}
function normalizeExportPath(path) {
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
}
function isDefaultSelectedExportCandidate(path) {
return (
path.startsWith('mods') ||
path.startsWith('datapacks') ||
path.startsWith('resourcepacks') ||
path.startsWith('shaderpacks') ||
path.startsWith('config')
)
}
function isExportCandidateDisabled(path) {
return (
path === 'profile.json' ||
path.startsWith('modrinth_logs') ||
path.startsWith('.fabric') ||
path.startsWith('__MACOSX')
)
}
</script>
<template>
<ModalWrapper ref="exportModal" :header="formatMessage(messages.header)">
<div class="flex flex-col gap-4 w-[40rem]">
<NewModal
ref="exportModal"
:header="formatMessage(messages.header)"
scrollable
width="46rem"
max-width="calc(100vw - 2rem)"
>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div class="labeled_input">
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
<div class="labeled_input w-full">
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
<StyledInput
v-model="nameInput"
:icon="PackageIcon"
type="text"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
clearable
wrapper-class="w-full"
/>
</div>
<div class="labeled_input">
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
<div class="labeled_input w-full">
<p class="text-contrast font-semibold">
{{ formatMessage(messages.versionNumberLabel) }}
</p>
<StyledInput
v-model="versionInput"
:icon="VersionIcon"
type="text"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
clearable
wrapper-class="w-full"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<p class="m-0">{{ formatMessage(commonMessages.descriptionLabel) }}</p>
<div class="flex flex-col gap-2 min-w-0">
<p class="m-0 text-contrast font-semibold">
{{ formatMessage(commonMessages.descriptionLabel) }}
</p>
<StyledInput
v-model="exportDescription"
multiline
:placeholder="formatMessage(messages.descriptionPlaceholder)"
wrapper-class="w-full"
/>
</div>
<Accordion
class="w-full bg-surface-4 border border-solid border-surface-5 rounded-2xl overflow-clip"
button-class="p-4 w-full border-b border-solid border-b-surface-5 bg-surface-2 -mb-px hover:brightness-[--hover-brightness] group"
>
<template #title>
<span class="flex items-center gap-3 text-contrast group-active:scale-[0.98]">
<WrenchIcon aria-hidden="true" class="size-5 text-secondary" />
Configure which files are included in this export
</span>
</template>
<div class="flex flex-col [&>*:nth-child(even)]:bg-surface-3">
<div v-for="[path, children] in folders" :key="path.name" class="flex flex-col">
<Accordion
class="flex flex-col"
button-class="flex gap-3 pr-4 hover:bg-surface-5 group"
>
<template #title>
<Checkbox
:model-value="children.every((child) => child.selected)"
:indeterminate="
!children.every((child) => child.selected) &&
children.some((child) => child.selected)
"
:description="formatMessage(messages.includeFile, { file: path.name })"
class="pl-4 py-2"
:disabled="children.every((x) => x.disabled)"
@update:model-value="
(newValue) => children.forEach((child) => (child.selected = newValue))
"
@click.stop
/>
<span class="ml-2 group-active:scale-95">{{ path.name }}/</span>
</template>
<div v-for="child in children" :key="child.path">
<Checkbox
v-model="child.selected"
:label="child.name"
class="w-full px-8 py-2 hover:bg-surface-4 text-primary"
:disabled="child.disabled"
/>
</div>
</Accordion>
</div>
<Checkbox
v-for="file in files"
:key="file.path"
v-model="file.selected"
:label="file.name"
:disabled="file.disabled"
class="w-full px-4 py-2 hover:bg-surface-4 text-primary"
/>
</div>
</Accordion>
<FileTreeSelect
:key="fileTreeKey"
v-model="selectedFilePaths"
class="min-w-0"
:items="files"
@navigate="loadExportDirectory"
/>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="exportModal.hide">
@@ -249,6 +299,6 @@ const exportPack = async () => {
</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
</template>
</NewModal>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import { MailIcon, SearchIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
@@ -301,11 +301,12 @@ const messages = defineMessages({
</ButtonStyled>
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchFriends)"
clearable
variant="outlined"
wrapper-class="flex-1"
input-class="!bg-transparent !border !border-solid !border-button-bg !text-primary !placeholder:text-primary"
wrapper-class="flex-1 [&>svg]:!text-primary [&>svg]:!opacity-100"
@keyup.esc="search = ''"
/>
</template>
@@ -122,9 +122,6 @@
"app.export-modal.modpack-name-placeholder": {
"message": "إسم حزمة التعديل"
},
"app.export-modal.select-files-label": {
"message": "أختيار الملفات التي سيتم تصديرها"
},
"app.export-modal.version-number-label": {
"message": "رقم الإصدار"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exportovat modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Zahrnout \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Název modpacku"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Název modpacku"
},
"app.export-modal.select-files-label": {
"message": "Nastavte, které soubory mají být do tohoto exportu zahrnuty"
},
"app.export-modal.version-number-label": {
"message": "Číslo verze"
},
@@ -164,18 +164,12 @@
"app.export-modal.header": {
"message": "Eksporter modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inkludere \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Navn"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack navn"
},
"app.export-modal.select-files-label": {
"message": "Konfigurer hvilke filer inkludere i denne eksport"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Modpack exportieren"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" einschliessen?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpaketname"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpaketname"
},
"app.export-modal.select-files-label": {
"message": "Konfiguriere, welche Dateien in diesem Export miteinbezogen werden"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Modpack exportieren"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" einschließen?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpackname"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpackname"
},
"app.export-modal.select-files-label": {
"message": "Konfiguriere, welche Dateien in diesen Export enthalten sind"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Export modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Include \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Name"
"message": "Modpack name"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack name"
},
"app.export-modal.select-files-label": {
"message": "Configure which files are included in this export"
},
"app.export-modal.version-number-label": {
"message": "Version number"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exportar modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "¿Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nombre del modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nombre del modpack"
},
"app.export-modal.select-files-label": {
"message": "Configura que archivos incluir en esta exportación"
},
"app.export-modal.version-number-label": {
"message": "Número de la versión"
},
@@ -182,18 +182,12 @@
"app.export-modal.header": {
"message": "Exportar modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "¿Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nombre del modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nombre del modpack"
},
"app.export-modal.select-files-label": {
"message": "Configura que archivos se incluirán en esta instancia"
},
"app.export-modal.version-number-label": {
"message": "Número de versión"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Vie modipaketti"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sisällytä \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modipaketin nimi"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modipaketin nimi"
},
"app.export-modal.select-files-label": {
"message": "Muuta mitkä tiedostot sisältyvät tähän vientiin"
},
"app.export-modal.version-number-label": {
"message": "Versio numero"
},
@@ -164,18 +164,12 @@
"app.export-modal.header": {
"message": "Iluwas ang modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Salihin ang \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Pangalan ng Modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Pangalan ng modpack"
},
"app.export-modal.select-files-label": {
"message": "Isaayos kung anong mga file ang isasali sa pagluwas"
},
"app.export-modal.version-number-label": {
"message": "Numero ng bersiyon"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exporter le modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inclure « {file} » ?"
},
"app.export-modal.modpack-name-label": {
"message": "Nom du modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nom du modpack"
},
"app.export-modal.select-files-label": {
"message": "Configurez quels fichiers sont inclus dans cette exportation"
},
"app.export-modal.version-number-label": {
"message": "Numéro de version"
},
@@ -176,18 +176,12 @@
"app.export-modal.header": {
"message": "Modcsomag exportálása"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Tartalmaz „{file}” fájlokat?"
},
"app.export-modal.modpack-name-label": {
"message": "A modcsomag neve"
},
"app.export-modal.modpack-name-placeholder": {
"message": "A modcsomag neve"
},
"app.export-modal.select-files-label": {
"message": "Állítsd be, hogy mely fájlok kerüljenek bele az exportba"
},
"app.export-modal.version-number-label": {
"message": "Verziószám"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Ekspor paket mod"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sertakan \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nama Paket Mod"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nama paket mod"
},
"app.export-modal.select-files-label": {
"message": "Konfigurasikan berkas mana yang disertakan dalam pengeksporan ini"
},
"app.export-modal.version-number-label": {
"message": "Nomor versi"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Esporta pacchetto"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Includere \"{file}\" nell'esportazione?"
},
"app.export-modal.modpack-name-label": {
"message": "Nome del pacchetto"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nome del pacchetto"
},
"app.export-modal.select-files-label": {
"message": "Seleziona i file da includere in questa esportazione"
},
"app.export-modal.version-number-label": {
"message": "Numero di versione"
},
@@ -164,18 +164,12 @@
"app.export-modal.header": {
"message": "모드팩 내보내기"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\"(을)를 포함할까요?"
},
"app.export-modal.modpack-name-label": {
"message": "모드팩 이름"
},
"app.export-modal.modpack-name-placeholder": {
"message": "모드팩 이름"
},
"app.export-modal.select-files-label": {
"message": "내보내기에 어느 파일이 포함될지 구성"
},
"app.export-modal.version-number-label": {
"message": "버전 구분"
},
@@ -155,18 +155,12 @@
"app.export-modal.header": {
"message": "Eksport pek mod"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Sertakan \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nama Pek Mod"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nama pek mod"
},
"app.export-modal.select-files-label": {
"message": "Konfigurasikan fail yang disertakan dalam eksport ini"
},
"app.export-modal.version-number-label": {
"message": "Nombor versi"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Exporteer modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Betrek \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpack Naam"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpack naam"
},
"app.export-modal.select-files-label": {
"message": "Configureer welke bestanden bij deze export zijn betrokken"
},
"app.export-modal.version-number-label": {
"message": "Versie nummer"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Eksportuj paczkę modów"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Dołączyć \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nazwa paczki modów"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nazwa paczki modów"
},
"app.export-modal.select-files-label": {
"message": "Skonfiguruje jakie pliki zostaną załączone w tym eksporcie"
},
"app.export-modal.version-number-label": {
"message": "Numer wersji"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exportar pacote de mods"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Incluir \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Nome do pacote de mods"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Nome do pacote de mods"
},
"app.export-modal.select-files-label": {
"message": "Configure quais arquivos serão incluídos na exportação"
},
"app.export-modal.version-number-label": {
"message": "Número da versão"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Экспорт сборки"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Добавить «{file}» в экспорт?"
},
"app.export-modal.modpack-name-label": {
"message": "Название сборки"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Название сборки"
},
"app.export-modal.select-files-label": {
"message": "Выберите файлы для экспорта"
},
"app.export-modal.version-number-label": {
"message": "Номер версии"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Exportera modpaket"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Inkludera \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Modpaketets namn"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Modpaketets namn"
},
"app.export-modal.select-files-label": {
"message": "Konfigurera vilka filer som inkluderas i denna export"
},
"app.export-modal.version-number-label": {
"message": "Versionsnummer"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "ส่งออกแพ็กม็อด"
},
"app.export-modal.include-file-accessibility-label": {
"message": "รวมถึง \"{file}\" ใช่หรือไม่"
},
"app.export-modal.modpack-name-label": {
"message": "ชื่อแพ็กม็อด"
},
"app.export-modal.modpack-name-placeholder": {
"message": "ชื่อแพ็กม็อด"
},
"app.export-modal.select-files-label": {
"message": "กำหนดไฟล์ที่ต้องการจะส่งออก"
},
"app.export-modal.version-number-label": {
"message": "หมายเลขเวอร์ชัน"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "Mod paketini dışa aktar"
},
"app.export-modal.include-file-accessibility-label": {
"message": "\"{file}\" dahil mi?"
},
"app.export-modal.modpack-name-label": {
"message": "Mod Paketi Adı"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Mod paketi adı"
},
"app.export-modal.select-files-label": {
"message": "Bu dışa aktarmaya hangi dosyaların dahil edileceğini yapılandırma"
},
"app.export-modal.version-number-label": {
"message": "Sürüm numarası"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Експортувати збірку"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Уключити «{file}»?"
},
"app.export-modal.modpack-name-label": {
"message": "Назва збірки"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Назва збірки"
},
"app.export-modal.select-files-label": {
"message": "Змініть файли, які додані до експорту"
},
"app.export-modal.version-number-label": {
"message": "Номер версії"
},
@@ -170,18 +170,12 @@
"app.export-modal.header": {
"message": "Xuất modpack"
},
"app.export-modal.include-file-accessibility-label": {
"message": "Bao gồm \"{file}\"?"
},
"app.export-modal.modpack-name-label": {
"message": "Tên modpack"
},
"app.export-modal.modpack-name-placeholder": {
"message": "Tên modpack"
},
"app.export-modal.select-files-label": {
"message": "Cấu hình các tệp nào được bao gồm trong quá trình xuất phiên bản này"
},
"app.export-modal.version-number-label": {
"message": "Phiên bản"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "导出整合包"
},
"app.export-modal.include-file-accessibility-label": {
"message": "包含 \"{file}\""
},
"app.export-modal.modpack-name-label": {
"message": "整合包名称"
},
"app.export-modal.modpack-name-placeholder": {
"message": "整合包名称"
},
"app.export-modal.select-files-label": {
"message": "配置此导出中包含哪些文件"
},
"app.export-modal.version-number-label": {
"message": "版本号"
},
@@ -203,18 +203,12 @@
"app.export-modal.header": {
"message": "匯出模組包"
},
"app.export-modal.include-file-accessibility-label": {
"message": "要包含「{file}」嗎?"
},
"app.export-modal.modpack-name-label": {
"message": "模組包名稱"
},
"app.export-modal.modpack-name-placeholder": {
"message": "模組包名稱"
},
"app.export-modal.select-files-label": {
"message": "設定要包含在此匯出檔案中的檔案"
},
"app.export-modal.version-number-label": {
"message": "版本號碼"
},
@@ -676,7 +676,7 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
break
case 'copy_path': {
if (instance.value) {
const fullPath = await get_full_path(instance.value?.path)
const fullPath = await get_full_path(instance.value.id)
await navigator.clipboard.writeText(fullPath)
}
break
@@ -1443,7 +1443,7 @@ onMounted(() => {
props.instance &&
event.instance_id === props.instance.id &&
event.event === 'synced' &&
props.instance.install_stage !== 'pack_installing' &&
props.instance.install_stage === 'installed' &&
!isBulkOperating.value
) {
await initProjects()
+6 -1
View File
@@ -414,7 +414,7 @@ components:
- unknown
- signature
example: required-resource-pack
# https://github.com/modrinth/labrinth/blob/master/src/routes/version_creation.rs#L27-L57
# https://github.com/modrinth/code/blob/main/apps/labrinth/src/routes/v2/version_creation.rs#L32-L76
CreatableVersion:
allOf:
- $ref: '#/components/schemas/BaseVersion'
@@ -446,6 +446,11 @@ components:
- client_or_server_prefers_both
- unknown
description: The environment that this version is for.
file_types:
type: object
additionalProperties:
$ref: '#/components/schemas/FileTypeEnum'
description: A map of file parts to their associated file type, a file type is used for additional files such as sources jars.
required:
- file_parts
- project_id
@@ -41,38 +41,38 @@ export const VISIBLE_PROJECT_STATUS_CHANGE_EVENT_STATUS_SET =
export const LIGHT_LEGEND_PALETTE = [
'hsl(152, 100%, 34%)',
'hsl(26, 100%, 42%)',
'hsl(202, 100%, 35%)',
'hsl(327, 45%, 64%)',
'hsl(41, 100%, 45%)',
'hsl(250, 60%, 33%)',
'hsl(170, 43%, 47%)',
'hsl(330, 60%, 33%)',
'hsl(46, 100%, 36%)',
'hsl(167, 100%, 30%)',
'hsl(343, 38%, 45%)',
'hsl(222, 100%, 28%)',
'hsl(270, 62%, 60%)',
'hsl(32, 100%, 37%)',
'hsl(349, 57%, 51%)',
'hsl(191, 43%, 37%)',
'hsl(41, 79%, 46%)',
'hsl(203, 76%, 64%)',
'hsl(0, 93%, 62%)',
'hsl(143, 66%, 29%)',
'hsl(58, 89%, 25%)',
'hsl(311, 64%, 49%)',
'hsl(198, 91%, 32%)',
'hsl(12, 88%, 27%)',
'hsl(236, 61%, 60%)',
'hsl(102, 59%, 74%)',
'hsl(293, 76%, 79%)',
'hsl(67, 99%, 41%)',
'hsl(179, 100%, 50%)',
'hsl(102, 100%, 61%)',
'hsl(0, 100%, 32%)',
]
export const DARK_LEGEND_PALETTE = [
'hsl(145, 78%, 48%)',
'hsl(41, 100%, 50%)',
'hsl(202, 77%, 63%)',
'hsl(323, 66%, 72%)',
'hsl(56, 85%, 60%)',
'hsl(255, 92%, 80%)',
'hsl(12, 100%, 67%)',
'hsl(176, 58%, 56%)',
'hsl(60, 100%, 41%)',
'hsl(165, 80%, 38%)',
'hsl(341, 36%, 56%)',
'hsl(226, 60%, 49%)',
'hsl(252, 53%, 62%)',
'hsl(75, 59%, 50%)',
'hsl(195, 56%, 42%)',
'hsl(30, 59%, 56%)',
'hsl(41, 79%, 46%)',
'hsl(203, 76%, 64%)',
'hsl(0, 93%, 62%)',
'hsl(143, 66%, 29%)',
'hsl(58, 94%, 45%)',
'hsl(311, 64%, 49%)',
'hsl(198, 91%, 32%)',
'hsl(12, 88%, 27%)',
'hsl(236, 61%, 60%)',
'hsl(102, 59%, 74%)',
'hsl(293, 76%, 79%)',
'hsl(61, 92%, 33%)',
'hsl(179, 100%, 50%)',
'hsl(102, 100%, 61%)',
'hsl(0, 100%, 32%)',
]
@@ -288,6 +288,7 @@ type PaletteRankEntry = {
key: string
label: string
total: number
excludedFromRank?: boolean
}
function formatDatasetTooltip(projectName: string | undefined): string | undefined {
@@ -330,9 +331,12 @@ function buildPaletteColorsByDownloadRank(
const colorsByKey = new Map<string, string>()
if (palette.length === 0) return colorsByKey
const sortedEntries = [...entries].sort(
(a, b) => b.total - a.total || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
)
const compareEntries = (a: PaletteRankEntry, b: PaletteRankEntry) =>
b.total - a.total || a.label.localeCompare(b.label) || a.key.localeCompare(b.key)
const rankedEntries = entries.filter((entry) => !entry.excludedFromRank).sort(compareEntries)
const excludedEntries = entries.filter((entry) => entry.excludedFromRank).sort(compareEntries)
const sortedEntries = [...rankedEntries, ...excludedEntries]
sortedEntries.forEach((entry, index) => {
colorsByKey.set(entry.key, getPaletteColorForIndex(index, palette))
})
@@ -340,6 +344,27 @@ function buildPaletteColorsByDownloadRank(
return colorsByKey
}
function isExcludedFromPaletteRank(breakdownValues: readonly string[]): boolean {
return breakdownValues.some(
(value) =>
isUnknownAnalyticsBreakdownValue(value) || isNoDependentAnalyticsBreakdownValue(value),
)
}
function buildPaletteRankEntry(
key: string,
breakdownValues: readonly string[],
total: number,
formatLabel: (breakdownValues: readonly string[]) => string,
): PaletteRankEntry {
return {
key,
label: formatLabel(breakdownValues),
total,
excludedFromRank: isExcludedFromPaletteRank(breakdownValues),
}
}
export function getMetricValue(
point: Labrinth.Analytics.v3.ProjectAnalytics,
activeStat: AnalyticsDashboardStat,
@@ -499,11 +524,14 @@ export function buildChartDatasets(
})
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
Array.from(dataByBreakdown.keys()).map((breakdownKey) => ({
key: breakdownKey,
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
total: downloadTotalsByBreakdown.get(breakdownKey) ?? 0,
})),
Array.from(dataByBreakdown.keys()).map((breakdownKey) =>
buildPaletteRankEntry(
breakdownKey,
breakdownValuesByKey.get(breakdownKey) ?? [],
downloadTotalsByBreakdown.get(breakdownKey) ?? 0,
formatChartBreakdownLabels,
),
),
palette,
)
@@ -538,7 +566,7 @@ export function buildChartDatasets(
? isNoDependentAnalyticsBreakdownValue(dependentProjectId)
? formatMessage(analyticsMessages.noDependentTooltip)
: isUnknownAnalyticsBreakdownValue(dependentProjectId)
? formatMessage(analyticsMessages.unknown)
? formatMessage(analyticsMessages.unknownDependentTooltip)
: formatDependentProjectDatasetTooltip(
versionName,
dependentProjectName,
@@ -675,11 +703,14 @@ export function buildChartDatasets(
})
const colorsByBreakdown = buildPaletteColorsByDownloadRank(
Array.from(dataByProjectBreakdown.keys()).map((breakdownKey) => ({
key: breakdownKey,
label: formatChartBreakdownLabels(breakdownValuesByKey.get(breakdownKey) ?? []),
total: downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0,
})),
Array.from(dataByProjectBreakdown.keys()).map((breakdownKey) =>
buildPaletteRankEntry(
breakdownKey,
breakdownValuesByKey.get(breakdownKey) ?? [],
downloadTotalsByProjectBreakdown.get(breakdownKey) ?? 0,
formatChartBreakdownLabels,
),
),
palette,
)
@@ -92,6 +92,11 @@ export const analyticsMessages = defineMessages({
id: 'analytics.value.no-dependent-tooltip',
defaultMessage: 'Downloaded for reasons other than being a dependency',
},
unknownDependentTooltip: {
id: 'analytics.value.unknown-dependent-tooltip',
defaultMessage:
"There's no metadata to determine which dependent project this download attributes to.",
},
other: {
id: 'analytics.value.other',
defaultMessage: 'Other',
@@ -515,7 +515,7 @@ function getDependentProjectTooltip(row: AnalyticsTableRow) {
return formatMessage(analyticsMessages.noDependentTooltip)
}
if (isUnknownAnalyticsBreakdownValue(row.breakdownValues.dependent_project_download)) {
return formatMessage(analyticsMessages.unknown)
return formatMessage(analyticsMessages.unknownDependentTooltip)
}
const dependencyProjectIds = new Set(row.dependentOnProjectIds)
@@ -73,6 +73,7 @@
:options="[userOption, ...ownerOptions]"
searchable
:disabled="hasHitLimit"
select-search-text-on-focus
show-icon-in-selected
/>
<span>{{ formatMessage(messages.ownerDescription) }}</span>
@@ -129,7 +130,7 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { PlusIcon, XIcon } from '@modrinth/assets'
import { OrganizationIcon, PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Chips,
@@ -362,7 +363,13 @@ async function fetchOrganizations() {
}),
),
)
: undefined,
: markRaw(
defineAsyncComponent(() =>
Promise.resolve({
setup: () => () => h(OrganizationIcon, { class: 'size-5' }),
}),
),
),
}))
if (props.organizationId) owner.value = props.organizationId
} catch (err) {
@@ -395,6 +402,7 @@ async function createProject() {
server_side: 'required',
license_id: 'LicenseRef-Unknown',
is_draft: true,
organization_id: owner.value !== 'self' ? owner.value : undefined,
}
formData.append('data', JSON.stringify(projectData))
+48
View File
@@ -5,6 +5,10 @@
<div class="pointer-events-none absolute inset-0 z-[-1]">
<div id="absolute-background-teleport" class="relative"></div>
</div>
<div
class="pride-backdrop pointer-events-none absolute inset-0 z-[-1]"
:class="{ shown: showPrideBackdrop }"
></div>
<div class="pointer-events-none absolute inset-0 z-50">
<div
class="over-the-top-random-animation"
@@ -801,6 +805,7 @@ import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { errors as generatedStateErrors } from '~/generated/state.json'
import { provideCurrentProjectId } from '~/providers/current-project.ts'
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'
import { hasActiveMidas } from '~/utils/user-membership.ts'
@@ -867,6 +872,32 @@ const showTinMismatchBanner = computed(() => {
return !!auth.value.user && status === 'tin-mismatch'
})
const PRIDE_COLLECTION_ID = 'M4c3ITvd'
const PRIDE_ARTICLE_SLUGS = ['pride-campaign-2025', 'pride-campaign-2026', 'proud-of-you-2026']
const PRIDE_CACHE_TIME = 1000 * 60 * 60 * 24
const { data: prideCollection } = useQuery({
queryKey: computed(() => ['collection', PRIDE_COLLECTION_ID]),
queryFn: () => client.labrinth.collections.get(PRIDE_COLLECTION_ID),
staleTime: PRIDE_CACHE_TIME,
gcTime: PRIDE_CACHE_TIME,
})
const prideProjectIds = computed(() => new Set(prideCollection.value?.projects ?? []))
const currentProjectId = ref()
provideCurrentProjectId(currentProjectId)
const showPrideBackdrop = computed(() => {
if (PRIDE_ARTICLE_SLUGS.includes(route.params.slug)) {
return true
}
if (route.params.collection === PRIDE_COLLECTION_ID) {
return true
}
return !!currentProjectId.value && prideProjectIds.value.has(currentProjectId.value)
})
const basePopoutId = useId()
async function fetchIntercomToken() {
@@ -1695,4 +1726,21 @@ const { cycle: changeTheme } = useTheme()
transform: translateY(0);
}
}
.pride-backdrop {
background-image: linear-gradient(to right, #c20732, #f57203, #ffd632, #21ca8b, #2f9ff2, #e420fc);
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1), rgba(0, 0, 0, 0) 80%);
height: 30rem;
opacity: 0;
transition: opacity 1s ease;
}
.pride-backdrop.shown {
opacity: 0.08;
}
.light-mode .pride-backdrop.shown,
.light .pride-backdrop.shown {
opacity: 0.15;
}
</style>
@@ -518,6 +518,9 @@
"analytics.value.unknown": {
"message": "Unknown"
},
"analytics.value.unknown-dependent-tooltip": {
"message": "There's no metadata to determine which dependent project this download attributes to."
},
"analytics.value.unmonetized": {
"message": "Unmonetized"
},
+12 -1
View File
@@ -1114,7 +1114,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import { nextTick, readonly, ref, useTemplateRef, watch } from 'vue'
import { nextTick, onScopeDispose, readonly, ref, useTemplateRef, watch, watchEffect } from 'vue'
import { navigateTo } from '#app'
import Accordion from '~/components/ui/Accordion.vue'
@@ -1130,6 +1130,7 @@ import { saveFeatureFlags } from '~/composables/featureFlags.ts'
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import { userCollectProject, userFollowProject } from '~/composables/user.js'
import { injectCurrentProjectId } from '~/providers/current-project.ts'
import {
loadChecklistOpenState,
saveChecklistOpenState,
@@ -1705,6 +1706,16 @@ const project = computed(() => {
// Use actual project ID for dependent queries (ensures cache consistency)
const projectId = computed(() => projectRaw.value?.id)
const sharedProjectId = injectCurrentProjectId(null)
if (sharedProjectId) {
watchEffect(() => {
sharedProjectId.value = projectId.value ?? undefined
})
onScopeDispose(() => {
sharedProjectId.value = undefined
})
}
// V3 Project
const {
data: projectV3,
@@ -954,5 +954,9 @@ function openEditModal(event) {
color: var(--color-brand);
font-weight: 600;
}
a:hover {
text-decoration: underline;
}
}
</style>
@@ -0,0 +1,9 @@
import { createContext } from '@modrinth/ui'
import type { Ref } from 'vue'
/**
* Informs the default layout of the current project ID, if any, because it can't be gleaned from the route which may be a slug
*/
export const [injectCurrentProjectId, provideCurrentProjectId] = createContext<
Ref<string | undefined>
>('root', 'currentProjectId')
Binary file not shown.

After

Width:  |  Height:  |  Size: 777 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

@@ -1,5 +1,12 @@
{
"articles": [
{
"title": "Proud of you all",
"summary": "Over 2,400 of you came together to raise more than $40,000 for charity this Pride month!",
"thumbnail": "https://modrinth.com/news/article/proud-of-you-2026/thumbnail.webp",
"date": "2026-07-02T01:00:00.000Z",
"link": "https://modrinth.com/news/article/proud-of-you-2026"
},
{
"title": "Improving Modpack review delays",
"summary": "Reducing the back-and-forth needed to get your modpack approved.",
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE AND user_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tselect\n\t\t\tf.url,\n\t\t\tf.version_id as \"version_id: DBVersionId\",\n\t\t\tv.mod_id as \"project_id: DBProjectId\"\n\t\tfrom files f\n\t\tinner join versions v on v.id = f.version_id\n\t\twhere f.id = $1\n\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "url",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "version_id: DBVersionId",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "project_id: DBProjectId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "17f415f1140df5b3dd42a161c0f77b2475edb8041bc2b9701d51cf9cbfd69ba1"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id,\n status AS \"status: PayoutStatus\"\n FROM payouts\n ORDER BY id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "status: PayoutStatus",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "20cff8fdf7971e91c9d473b9a4663ce02ca16781e32232ae0fa7a0af1973d3a4"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tdelete from attributions_exemptions\n\t\twhere version_id = $1\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "5ed1e727901573ee92683f206f3321fa4e192c75caa5a62a2790b5136e9a7dde"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO payouts_values_notifications (date_available, user_id, notified)\n VALUES ($1, $2, FALSE)\n ON CONFLICT (date_available, user_id) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz",
"Int8"
]
},
"nullable": []
},
"hash": "6678cd4c51771cfaae2be8021ba66908ea41a06ba858dc5b523aef6aae27b850"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n select\n (\n select count(*)\n from project_attribution_groups\n where project_id = $1\n ) as \"groups!\",\n (\n select count(*)\n from project_attribution_files paf\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where pag.project_id = $1\n ) as \"files!\"\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "groups!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "files!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null,
null
]
},
"hash": "686994a5bfc061b4c6e1ca594fae9d5d3fb18974de6605a650f666d440dfe684"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO payouts_values (user_id, mod_id, amount, created, date_available)\n VALUES ($1, NULL, $2, NOW(), $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Numeric",
"Timestamptz"
]
},
"nullable": []
},
"hash": "69a1cb4b7f1115a990d1fc4805d58541fc78e910111c09ba3d50a12d9ca4a9f8"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT status FROM mods WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "status",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "91d2ce7ee6a29a47a20655fef577c42f1cbb2f8de4d9ea8ab361fc210f08aa20"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET status = 'rejected'\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "95e17b2512494ffcbfe6278b87aa273edc5729633aeaa87f6239667d2f861e68"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT status AS \"status: PayoutStatus\" FROM payouts WHERE id = 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "status: PayoutStatus",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, $3, $4, $5, 10.0, NOW())\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Text",
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, NULL, $3, $4, 10.00, NOW())\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND body->>'type' = 'payout_available'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "fd5c773a61d35bcd71503ec4d5f86e8917cfab9679d5064074681663ba467e41"
}
+1 -2
View File
@@ -100,6 +100,7 @@ rust_decimal = { workspace = true, features = [
rust_iso3166 = { workspace = true }
rustls.workspace = true
rusty-money = { workspace = true }
scalar_api_reference = { workspace = true, features = ["actix-web"] }
sentry = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
@@ -128,8 +129,6 @@ tracing-actix-web = { workspace = true }
url = { workspace = true }
urlencoding = { workspace = true }
utoipa = { workspace = true, features = ["url"] }
utoipa-actix-web = { workspace = true }
utoipa-scalar = { workspace = true, features = ["actix-web"] }
uuid = { workspace = true, features = ["fast-rng", "serde", "v4", "v7"] }
validator = { workspace = true, features = ["derive"] }
webauthn-rs = { workspace = true, features = [
@@ -0,0 +1,6 @@
INSERT INTO notifications_types
(name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications)
VALUES ('shared_instance_invite', 1, FALSE, TRUE);
INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
VALUES (NULL, 'email', 'shared_instance_invite', FALSE);
@@ -0,0 +1,7 @@
Modrinth Internal API.
> [!caution]
> This API is internal and is subject to breaking changes without notice. Please avoid using it in production.
> [!tip]
> You're probably looking for [Modrinth's public API v2](/docs?api=v2)
+101
View File
@@ -0,0 +1,101 @@
> [!success]
> API v2 is Modrinth's current, official API. It is the recommended API for integrations and applications and will receive long-term support.
## Authentication
This API has two options for authentication: personal access tokens and [OAuth2](https://en.wikipedia.org/wiki/OAuth).
All tokens are tied to a Modrinth user and use the `Authorization` header of the request.
Example:
```
Authorization: mrp_RNtLRSPmGj2pd1v1ubi52nX7TJJM9sznrmwhAuj511oe4t1jAqAQ3D6Wc8Ic
```
> [!tip] You do not need a token for most requests.
>
> Generally speaking, only the following types of requests require a token:
> - those which create data (such as version creation)
> - those which modify data (such as editing a project)
> - those which access private data (such as draft projects, notifications, emails, and payout data)
Each request requiring authentication has a certain scope. For example, to view the email of the user being requested, the token must have the `USER_READ_EMAIL` scope.
You can find the list of available scopes [on GitHub](https://github.com/modrinth/code/blob/main/apps/labrinth/src/models/v3/pats.rs#L9). Making a request with an invalid scope will return a 401 error.
Please note that certain scopes and requests cannot be completed with a personal access token or using OAuth.
For example, deleting a user account can only be done through Modrinth's frontend.
### Personal access tokens
Personal access tokens (PATs) can be generated from [the user settings](https://modrinth.com/settings/account).
### GitHub tokens
For backwards compatibility purposes, some types of GitHub tokens also work for authenticating a user with Modrinth's API, granting all scopes.
**We urge any application still using GitHub tokens to start using personal access tokens for security and reliability purposes.**
## Cross-Origin Resource Sharing
This API features Cross-Origin Resource Sharing (CORS) implemented in compliance with the [W3C spec](https://www.w3.org/TR/cors/).
This allows for cross-domain communication from the browser.
All responses have a wildcard same-origin which makes them completely public and accessible to everyone, including any code on any site.
## Identifiers
The majority of items you can interact with in the API have a unique eight-digit base62 ID.
Projects, versions, users, threads, teams, and reports all use this same way of identifying themselves.
Version files use the sha1 or sha512 file hashes as identifiers.
Each project and user has a friendlier way of identifying them; slugs and usernames, respectively.
While unique IDs are constant, slugs and usernames can change at any moment.
If you want to store something in the long term, it is recommended to use the unique ID.
## Ratelimits
The API has a ratelimit defined per IP. Limits and remaining amounts are given in the response headers.
- `X-Ratelimit-Limit`: the maximum number of requests that can be made in a minute
- `X-Ratelimit-Remaining`: the number of requests remaining in the current ratelimit window
- `X-Ratelimit-Reset`: the time in seconds until the ratelimit window resets
**Ratelimits are the same no matter whether you use a token or not.**
The ratelimit is currently 300 requests per minute per IP. If your application is hitting rate limits, try caching responses, using batch requests, or spreading out your requests over time.
Higher rate limits are granted only in very rare cases. If you believe your use case requires an exception, please [contact us](mailto:support@modrinth.com).
## User Agents
To access the Modrinth API, you **must** use provide a uniquely-identifying `User-Agent` header.
Providing a user agent that only identifies your HTTP client library (such as "okhttp/4.9.3") increases the likelihood that we will block your traffic.
It is recommended, but not required, to include contact information in your user agent.
This allows us to contact you if we would like a change in your application's behavior without having to block your traffic.
- Bad: `User-Agent: okhttp/4.9.3`
- Good: `User-Agent: project_name`
- Better: `User-Agent: github_username/project_name/1.56.0`
- Best: `User-Agent: github_username/project_name/1.56.0 (launcher.com)` or `User-Agent: github_username/project_name/1.56.0 (contact@launcher.com)`
## Versioning
Modrinth follows a simple pattern for its API versioning.
In the event of a breaking API change, the API version in the URL path is bumped, and migration steps will be published below.
### Migrations
Inside the following spoiler, you will be able to find all changes between versions of the Modrinth API, accompanied by tips and a guide to migrate applications to newer versions.
<details><summary>API v1 to API v2</summary>
These bullet points cover most changes in the v2 API, but please note that fields containing `mod` in most contexts have been shifted to `project`. For example, in the search route, the field `mod_id` was renamed to `project_id`.
- The search route has been moved from `/api/v1/mod` to `/v2/search`
- New project fields: `project_type` (may be `mod` or `modpack`), `moderation_message` (which has a `message` and `body`), `gallery`
- New search facet: `project_type`
- Alphabetical sort removed (it didn't work and is not possible due to limits in MeiliSearch)
- New search fields: `project_type`, `gallery`
- The gallery field is an array of URLs to images that are part of the project's gallery
- The gallery is a new feature which allows the user to upload images showcasing their mod to the CDN which will be displayed on their mod page
- Internal change: Any project file uploaded to Modrinth is now validated to make sure it's a valid Minecraft mod, Modpack, etc.
- For example, a Forge 1.17 mod with a JAR not containing a mods.toml will not be allowed to be uploaded to Modrinth
- In project creation, projects may not upload a mod with no versions to review, however they can be saved as a draft
- Similarly, for version creation, a version may not be uploaded without any files
- Donation URLs have been enabled
- New project status: `archived`. Projects with this status do not appear in search
- Tags (such as categories, loaders) now have icons (SVGs) and specific project types attached
- Dependencies have been wiped and replaced with a new system
- Notifications now have a `type` field, such as `project_update`
Along with this, project subroutes (such as `/v2/project/{id}/version`) now allow the slug to be used as the ID. This is also the case with user routes.
<br />
The above snippet about User Agents was adapted from https://crates.io/policies, copyright (c) 2014 The Rust Project Developers under MIT license.
+8
View File
@@ -0,0 +1,8 @@
> [!caution]
> API v3 is Modrinth's experimental API.
>
> **Please do not use this API in production. It will be deprecated in the future.**
> [!tip]
> You're probably looking for [Modrinth's public API v2](/docs?api=v2)
+28 -4
View File
@@ -18,7 +18,7 @@ use crate::models::ids::OAuthClientId;
use crate::models::pats::Scopes;
use crate::queue::session::AuthQueue;
use actix_web::http::header::{CACHE_CONTROL, LOCATION, PRAGMA};
use actix_web::web::{Data, Query, ServiceConfig};
use actix_web::web::{Data, Query};
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use chrono::{DateTime, Duration};
use rand::distributions::Alphanumeric;
@@ -33,7 +33,7 @@ use super::AuthenticationError;
pub mod errors;
pub mod uris;
pub fn config(cfg: &mut ServiceConfig) {
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(init_oauth)
.service(accept_client_scopes)
.service(reject_client_scopes)
@@ -57,6 +57,18 @@ pub struct OAuthClientAccessRequest {
pub requested_scopes: Scopes,
}
#[utoipa::path(
context_path = "/oauth",
path = "/authorize",
tag = "oauth",
params(
("client_id" = OAuthClientId, Query),
("redirect_uri" = Option<String>, Query),
("scope" = Option<String>, Query),
("state" = Option<String>, Query)
),
responses((status = OK))
)]
#[get("authorize")]
pub async fn init_oauth(
req: HttpRequest,
@@ -165,11 +177,15 @@ pub async fn init_oauth(
}
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct RespondToOAuthClientScopes {
pub flow: String,
}
#[utoipa::path(
context_path = "/oauth",
path = "/accept", tag = "oauth", responses((status = OK))
)]
#[post("accept")]
pub async fn accept_client_scopes(
req: HttpRequest,
@@ -189,6 +205,10 @@ pub async fn accept_client_scopes(
.await
}
#[utoipa::path(
context_path = "/oauth",
path = "/reject", tag = "oauth", responses((status = OK))
)]
#[post("reject")]
pub async fn reject_client_scopes(
req: HttpRequest,
@@ -201,7 +221,7 @@ pub async fn reject_client_scopes(
.await
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct TokenRequest {
pub grant_type: String,
pub code: String,
@@ -216,6 +236,10 @@ pub struct TokenResponse {
pub expires_in: i64,
}
#[utoipa::path(
context_path = "/oauth",
path = "/token", tag = "oauth", responses((status = OK))
)]
#[post("token")]
/// Params should be in the urlencoded request body
/// And client secret should be in the HTTP basic authorization header
+42 -30
View File
@@ -20,7 +20,6 @@ use crate::background_task::update_versions;
use crate::database::{PgPool, ReadOnlyPgPool};
use crate::env::ENV;
use crate::queue::billing::{index_billing, index_subscriptions};
use crate::queue::moderation::AutomatedModerationQueue;
use crate::routes::internal::delphi::rescan::rescan_projects_in_queue;
use crate::util::anrok;
use crate::util::archon::ArchonClient;
@@ -68,7 +67,6 @@ pub struct LabrinthConfig {
pub payouts_queue: web::Data<PayoutsQueue>,
pub analytics_queue: Arc<AnalyticsQueue>,
pub active_sockets: web::Data<ActiveSockets>,
pub automated_moderation_queue: web::Data<AutomatedModerationQueue>,
pub rate_limiter: web::Data<AsyncRateLimiter>,
pub stripe_client: stripe::Client,
pub anrok_client: anrok::Client,
@@ -98,21 +96,6 @@ pub fn app_setup(
) -> LabrinthConfig {
info!("Starting labrinth on {}", &ENV.BIND_ADDR);
let automated_moderation_queue =
web::Data::new(AutomatedModerationQueue::default());
{
let automated_moderation_queue_ref = automated_moderation_queue.clone();
let pool_ref = pool.clone();
let ro_pool_ref = ro_pool.clone();
let redis_pool_ref = redis_pool.clone();
actix_rt::spawn(async move {
automated_moderation_queue_ref
.task(pool_ref, ro_pool_ref, redis_pool_ref)
.await;
});
}
let scheduler = scheduler::Scheduler::new();
let http_client = web::Data::new(HttpClient::new());
@@ -341,7 +324,6 @@ pub fn app_setup(
payouts_queue: web::Data::new(PayoutsQueue::new()),
analytics_queue,
active_sockets,
automated_moderation_queue,
rate_limiter: limiter,
stripe_client,
anrok_client,
@@ -361,6 +343,14 @@ pub fn app_setup(
pub fn app_config(
cfg: &mut web::ServiceConfig,
labrinth_config: LabrinthConfig,
) {
app_data_config(cfg, labrinth_config);
app_fallback_config(cfg);
}
pub fn app_data_config(
cfg: &mut web::ServiceConfig,
labrinth_config: LabrinthConfig,
) {
cfg.app_data(web::FormConfig::default().error_handler(|err, _req| {
routes::ApiError::Validation(err.to_string()).into()
@@ -391,23 +381,23 @@ pub fn app_config(
.app_data(web::Data::new(labrinth_config.analytics_queue.clone()))
.app_data(web::Data::new(labrinth_config.clickhouse.clone()))
.app_data(labrinth_config.active_sockets.clone())
.app_data(labrinth_config.automated_moderation_queue.clone())
.app_data(labrinth_config.archon_client.clone())
.app_data(web::Data::new(labrinth_config.stripe_client.clone()))
.app_data(web::Data::new(labrinth_config.anrok_client.clone()))
.app_data(labrinth_config.rate_limiter.clone())
.app_data(labrinth_config.kafka_client.clone())
.app_data(labrinth_config.search_state.clone())
.app_data(labrinth_config.webauthn.clone())
.configure(routes::v3::config)
.configure(routes::internal::config)
.configure(routes::root_config)
.default_service(web::get().wrap(default_cors()).to(routes::not_found));
.app_data(labrinth_config.webauthn.clone());
}
pub fn utoipa_app_config(
cfg: &mut utoipa_actix_web::service_config::ServiceConfig,
_labrinth_config: LabrinthConfig,
pub fn app_fallback_config(cfg: &mut web::ServiceConfig) {
cfg.configure(routes::root_config)
.default_service(web::get().to(routes::not_found).wrap(default_cors()));
}
pub fn app_routes_config(
cfg: &mut web::ServiceConfig,
labrinth_config: LabrinthConfig,
) {
cfg.configure({
#[cfg(target_os = "linux")]
@@ -419,7 +409,29 @@ pub fn utoipa_app_config(
|_cfg| ()
}
})
.configure(routes::v2::utoipa_config)
.configure(routes::v3::utoipa_config)
.configure(routes::internal::utoipa_config);
.configure(|cfg| app_routes_config_v2(cfg, labrinth_config.clone()))
.configure(|cfg| app_routes_config_v3(cfg, labrinth_config.clone()))
.configure(|cfg| app_routes_config_internal(cfg, labrinth_config));
}
pub fn app_routes_config_v2(
cfg: &mut web::ServiceConfig,
_labrinth_config: LabrinthConfig,
) {
cfg.configure(routes::v2::config);
}
pub fn app_routes_config_v3(
cfg: &mut web::ServiceConfig,
_labrinth_config: LabrinthConfig,
) {
cfg.configure(routes::public_config)
.configure(routes::v3::config);
}
pub fn app_routes_config_internal(
cfg: &mut web::ServiceConfig,
_labrinth_config: LabrinthConfig,
) {
cfg.configure(routes::internal::config);
}
+169 -24
View File
@@ -2,7 +2,7 @@
use actix_web::dev::Service;
use actix_web::middleware::from_fn;
use actix_web::{App, HttpServer, web};
use actix_web::{App, HttpResponse, HttpServer, web};
use actix_web_prom::PrometheusMetricsBuilder;
use clap::Parser;
@@ -15,17 +15,22 @@ use labrinth::search;
use labrinth::util::anrok;
use labrinth::util::gotenberg::GotenbergClient;
use labrinth::util::ratelimit::rate_limit_middleware;
use labrinth::utoipa_app_config;
use labrinth::{app_config, env};
use labrinth::{app_data_config, app_fallback_config, env};
use labrinth::{
app_routes_config_internal, app_routes_config_v2, app_routes_config_v3,
};
use labrinth::{clickhouse, database, file_hosting};
use scalar_api_reference::actix_web::config as scalar_config;
use serde_json::json;
use std::ffi::CStr;
use std::sync::Arc;
use tracing::{Instrument, info, info_span};
use tracing_actix_web::TracingLogger;
use utoipa::OpenApi;
use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme};
use utoipa_actix_web::AppExt;
use utoipa_scalar::Servable;
use utoipa::PartialSchema;
use utoipa::openapi::Content;
use utoipa::openapi::response::Response;
use utoipa::openapi::schema::Components;
#[cfg(target_os = "linux")]
#[global_allocator]
@@ -223,7 +228,16 @@ async fn app() -> std::io::Result<()> {
info!("Starting Actix HTTP server!");
HttpServer::new(move || {
App::new()
let mut docs_v2 = labrinth::routes::v2::ApiDoc::openapi();
let mut docs_v3 = labrinth::routes::v3::ApiDoc::openapi();
let mut docs_internal = labrinth::routes::internal::ApiDoc::openapi();
#[cfg(target_os = "linux")]
docs_v3.merge(labrinth::routes::debug::ApiDoc::openapi());
document_error_responses(&mut docs_v2);
document_error_responses(&mut docs_v3);
document_error_responses(&mut docs_internal);
let app = App::new()
.wrap(TracingLogger::default())
.wrap_fn(|req, srv| {
// We capture the same fields as `tracing-actix-web`'s `RootSpanBuilder`.
@@ -260,31 +274,162 @@ async fn app() -> std::io::Result<()> {
// transactions out of HTTP requests. However, we have to use our
// own - See `sentry::SentryErrorReporting` for why.
.wrap(labrinth::util::sentry::SentryErrorReporting)
// Use `utoipa` for OpenAPI generation
.into_utoipa_app()
.configure(|cfg| utoipa_app_config(cfg, labrinth_config.clone()))
.openapi_service(|api| utoipa_scalar::Scalar::with_url("/docs", ApiDoc::openapi().merge_from(api)))
.into_app()
.configure(|cfg| app_config(cfg, labrinth_config.clone()))
.configure(|cfg| app_data_config(cfg, labrinth_config.clone()))
.configure(|cfg| app_routes_config_v2(cfg, labrinth_config.clone()))
.configure(|cfg| app_routes_config_v3(cfg, labrinth_config.clone()))
.configure(|cfg| {
app_routes_config_internal(cfg, labrinth_config.clone())
});
let scalar_configuration = json!({
"sources": [
{
"title": "API v2",
"slug": "v2",
"url": "/openapi/v2.json",
"default": true
},
{
"title": "API v3 (UNSTABLE)",
"slug": "v3",
"url": "/openapi/v3.json"
},
{
"title": "Internal API (HIGHLY UNSTABLE)",
"slug": "internal",
"url": "/openapi/internal.json"
}
],
"agent": {
"disabled": true
},
"mcp": {
"disabled": true
},
"telemetry": false,
"metaData": {
"title": "Modrinth API Documentation",
"description": "Reference documentation for the Modrinth API.",
"ogTitle": "Modrinth API Documentation",
"ogDescription": "Reference documentation for the Modrinth API."
},
"modelsSectionLabel": "Schemas",
"defaultOpenFirstTag": true,
"defaultOpenAllTags": false,
"expandAllResponses": false,
"expandAllSchemaProperties": false,
"expandAllModelSections": false,
"orderSchemaPropertiesBy": "preserve",
"orderRequiredPropertiesFirst": true,
"hideSearch": false,
"searchHotKey": "k",
"showOperationId": false,
"defaultHttpClient": {
"targetKey": "shell",
"clientKey": "curl"
},
"persistAuth": false,
"showDeveloperTools": "never",
});
app.service(openapi_json_service("/openapi/v2.json", docs_v2))
.service(openapi_json_service("/openapi/v3.json", docs_v3))
.service(openapi_json_service(
"/openapi/internal.json",
docs_internal,
))
.configure(scalar_config("/docs", &scalar_configuration))
.configure(app_fallback_config)
})
.bind(&ENV.BIND_ADDR)?
.run()
.await
}
#[derive(utoipa::OpenApi)]
#[openapi(info(title = "Labrinth"), modifiers(&SecurityAddon))]
struct ApiDoc;
fn openapi_json_service(
path: &'static str,
openapi: utoipa::openapi::OpenApi,
) -> actix_web::Resource {
web::resource(path).route(web::get().to(move || {
let openapi = openapi.clone();
async move { openapi_json(openapi) }
}))
}
struct SecurityAddon;
fn openapi_json(openapi: utoipa::openapi::OpenApi) -> HttpResponse {
match serde_json::to_string_pretty(&openapi) {
Ok(body) => HttpResponse::Ok()
.content_type("application/json; charset=utf-8")
.body(body),
Err(error) => {
tracing::error!(%error, "Failed to serialize OpenAPI schema");
HttpResponse::InternalServerError().finish()
}
}
}
impl utoipa::Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.as_mut().unwrap();
components.add_security_scheme(
"bearer_auth",
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new(
"authorization",
fn document_error_responses(openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Components::new);
components.schemas.insert(
"ApiError".to_string(),
labrinth::models::error::ApiError::schema(),
);
for path_item in openapi.paths.paths.values_mut() {
add_default_error_response(&mut path_item.get);
add_default_error_response(&mut path_item.put);
add_default_error_response(&mut path_item.post);
add_default_error_response(&mut path_item.delete);
add_default_error_response(&mut path_item.options);
add_default_error_response(&mut path_item.head);
add_default_error_response(&mut path_item.patch);
add_default_error_response(&mut path_item.trace);
}
}
fn add_default_error_response(
operation: &mut Option<utoipa::openapi::path::Operation>,
) {
if let Some(operation) = operation {
for (status, response) in &mut operation.responses.responses {
if !is_error_response_status(status) {
continue;
}
if let utoipa::openapi::RefOr::T(response) = response {
add_error_content(response);
}
}
operation
.responses
.responses
.entry("500".to_string())
.or_insert_with(|| error_response().into());
}
}
fn is_error_response_status(status: &str) -> bool {
matches!(status.as_bytes().first(), Some(b'4' | b'5'))
|| status == "default"
}
fn error_response() -> Response {
let mut response = Response::new("Error response");
add_error_content(&mut response);
response
}
fn add_error_content(response: &mut Response) {
if response.content.is_empty() {
response.content.insert(
"application/json".to_string(),
Content::new(Some(utoipa::openapi::Ref::from_schema_name(
"ApiError",
))),
);
}
+3 -1
View File
@@ -1,10 +1,12 @@
use serde::{Deserialize, Serialize};
/// An error returned by the API
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct ApiError<'a> {
#[schema(value_type = String)]
pub error: &'a str,
pub description: String,
#[schema(value_type = Option<serde_json::Value>)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
+19 -3
View File
@@ -1,3 +1,5 @@
#![allow(clippy::large_stack_arrays)]
use crate::models::ids::{ThreadMessageId, VersionId};
use crate::models::v3::billing::PriceDuration;
use crate::models::{
@@ -13,7 +15,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct LegacyNotification {
pub id: NotificationId,
pub user_id: UserId,
@@ -30,14 +32,14 @@ pub struct LegacyNotification {
pub actions: Vec<LegacyNotificationAction>,
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct LegacyNotificationAction {
pub title: String,
/// The route to call when this notification action is called. Formatted HTTP Method, route
pub action_route: (String, String),
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LegacyNotificationBody {
TaxNotification {
@@ -73,6 +75,10 @@ pub enum LegacyNotificationBody {
invited_by: UserId,
role: String,
},
SharedInstanceInvite {
shared_instance_id: String,
shared_instance_name: String,
},
StatusChange {
project_id: ProjectId,
old_status: ProjectStatus,
@@ -177,6 +183,9 @@ impl LegacyNotification {
NotificationBody::ServerInvite { .. } => {
Some("server_invite".to_string())
}
NotificationBody::SharedInstanceInvite { .. } => {
Some("shared_instance_invite".to_string())
}
NotificationBody::StatusChange { .. } => {
Some("status_change".to_string())
}
@@ -294,6 +303,13 @@ impl LegacyNotification {
invited_by,
role,
},
NotificationBody::SharedInstanceInvite {
shared_instance_id,
shared_instance_name,
} => LegacyNotificationBody::SharedInstanceInvite {
shared_instance_id,
shared_instance_name,
},
NotificationBody::StatusChange {
project_id,
old_status,
+2 -2
View File
@@ -4,7 +4,7 @@ use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct LegacyReport {
pub id: ReportId,
pub report_type: String,
@@ -17,7 +17,7 @@ pub struct LegacyReport {
pub thread_id: ThreadId,
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
#[serde(rename_all = "kebab-case")]
pub enum LegacyItemType {
Project,
+2 -2
View File
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
use crate::{routes::v2_reroute, search::ResultSearchProject};
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)]
pub struct LegacySearchResults {
pub hits: Vec<LegacyResultSearchProject>,
pub offset: usize,
@@ -11,7 +11,7 @@ pub struct LegacySearchResults {
pub total_hits: usize,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, utoipa::ToSchema)]
pub struct LegacyResultSearchProject {
pub project_id: String,
pub project_type: String,
+1 -1
View File
@@ -8,7 +8,7 @@ use crate::models::{
};
/// A member of a team
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct LegacyTeamMember {
pub role: String,
// is_owner removed, and role hardcoded to Owner if true,
+6 -4
View File
@@ -7,7 +7,7 @@ use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct LegacyThread {
pub id: ThreadId,
#[serde(rename = "type")]
@@ -18,7 +18,7 @@ pub struct LegacyThread {
pub members: Vec<User>,
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct LegacyThreadMessage {
pub id: ThreadMessageId,
pub author_id: Option<UserId>,
@@ -26,7 +26,7 @@ pub struct LegacyThreadMessage {
pub created: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LegacyMessageBody {
Text {
@@ -49,7 +49,9 @@ pub enum LegacyMessageBody {
},
}
#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)]
#[derive(
Serialize, Deserialize, Eq, PartialEq, Copy, Clone, utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum LegacyThreadType {
Report,
+1 -1
View File
@@ -6,7 +6,7 @@ use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
#[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)]
pub struct LegacyUser {
pub id: UserId,
pub username: String,
+11 -1
View File
@@ -75,7 +75,17 @@ impl Price {
}
}
#[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Debug, Copy, Clone)]
#[derive(
Serialize,
Deserialize,
Hash,
Eq,
PartialEq,
Debug,
Copy,
Clone,
utoipa::ToSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum PriceDuration {
FiveDays,
@@ -28,7 +28,7 @@ impl From<crate::database::models::DBModerationNote> for ModerationNote {
}
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct PatchModerationNote {
pub notes: Option<String>,
pub user_rating: Option<i32>,
+37 -1
View File
@@ -36,6 +36,7 @@ pub enum NotificationType {
TeamInvite,
OrganizationInvite,
ServerInvite,
SharedInstanceInvite,
StatusChange,
ModeratorMessage,
LegacyMarkdown,
@@ -71,6 +72,7 @@ impl NotificationType {
NotificationType::TeamInvite => "team_invite",
NotificationType::OrganizationInvite => "organization_invite",
NotificationType::ServerInvite => "server_invite",
NotificationType::SharedInstanceInvite => "shared_instance_invite",
NotificationType::StatusChange => "status_change",
NotificationType::ModeratorMessage => "moderator_message",
NotificationType::LegacyMarkdown => "legacy_markdown",
@@ -112,6 +114,7 @@ impl NotificationType {
"team_invite" => NotificationType::TeamInvite,
"organization_invite" => NotificationType::OrganizationInvite,
"server_invite" => NotificationType::ServerInvite,
"shared_instance_invite" => NotificationType::SharedInstanceInvite,
"status_change" => NotificationType::StatusChange,
"moderator_message" => NotificationType::ModeratorMessage,
"legacy_markdown" => NotificationType::LegacyMarkdown,
@@ -173,6 +176,10 @@ pub enum NotificationBody {
invited_by: UserId,
role: String,
},
SharedInstanceInvite {
shared_instance_id: String,
shared_instance_name: String,
},
StatusChange {
project_id: ProjectId,
old_status: ProjectStatus,
@@ -288,6 +295,9 @@ impl NotificationBody {
NotificationBody::ServerInvite { .. } => {
NotificationType::ServerInvite
}
NotificationBody::SharedInstanceInvite { .. } => {
NotificationType::SharedInstanceInvite
}
NotificationBody::StatusChange { .. } => {
NotificationType::StatusChange
}
@@ -470,6 +480,32 @@ impl From<DBNotification> for Notification {
},
],
),
NotificationBody::SharedInstanceInvite {
shared_instance_name,
..
} => (
"You have been invited to a shared instance!".to_string(),
format!(
"An invite has been sent for you to join {shared_instance_name}"
),
"#".to_string(),
vec![
NotificationAction {
name: "Accept".to_string(),
action_route: (
"POST".to_string(),
String::new(),
),
},
NotificationAction {
name: "Deny".to_string(),
action_route: (
"POST".to_string(),
String::new(),
),
},
],
),
NotificationBody::StatusChange {
old_status,
new_status,
@@ -670,7 +706,7 @@ impl From<DBNotification> for Notification {
}
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct NotificationAction {
pub name: String,
/// The route to call when this notification action is called. Formatted HTTP Method, route
+4 -4
View File
@@ -10,14 +10,14 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
#[derive(Deserialize, Serialize)]
#[derive(Deserialize, Serialize, utoipa::ToSchema)]
pub struct OAuthRedirectUri {
pub id: OAuthRedirectUriId,
pub client_id: OAuthClientId,
pub uri: String,
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct OAuthClientCreationResult {
#[serde(flatten)]
pub client: OAuthClient,
@@ -25,7 +25,7 @@ pub struct OAuthClientCreationResult {
pub client_secret: String,
}
#[derive(Deserialize, Serialize)]
#[derive(Deserialize, Serialize, utoipa::ToSchema)]
pub struct OAuthClient {
pub id: OAuthClientId,
pub name: String,
@@ -48,7 +48,7 @@ pub struct OAuthClient {
pub description: Option<String>,
}
#[derive(Deserialize, Serialize)]
#[derive(Deserialize, Serialize, utoipa::ToSchema)]
pub struct OAuthClientAuthorization {
pub id: OAuthClientAuthorizationId,
pub app_id: OAuthClientId,
+1 -1
View File
@@ -4,7 +4,7 @@ use crate::models::ids::{OrganizationId, TeamId};
use serde::{Deserialize, Serialize};
/// An organization of users who control a project
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct Organization {
/// The id of the organization
pub id: OrganizationId,
+14 -3
View File
@@ -234,7 +234,7 @@ impl PayoutStatus {
}
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct PayoutMethod {
pub id: String,
#[serde(rename = "type")]
@@ -252,7 +252,7 @@ pub struct PayoutMethod {
pub exchange_rate: Option<Decimal>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, utoipa::ToSchema)]
pub struct PayoutMethodFee {
#[serde(with = "rust_decimal::serde::float")]
pub percentage: Decimal,
@@ -274,6 +274,17 @@ impl PayoutMethodFee {
#[derive(Clone)]
pub struct PayoutDecimal(pub Decimal);
impl utoipa::PartialSchema for PayoutDecimal {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::Type::Number)
.build()
.into()
}
}
impl utoipa::ToSchema for PayoutDecimal {}
impl Serialize for PayoutDecimal {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
@@ -293,7 +304,7 @@ impl<'de> Deserialize<'de> for PayoutDecimal {
}
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum PayoutInterval {
Standard {
+2
View File
@@ -8,6 +8,7 @@ use crate::models::exp;
use crate::models::ids::{
FileId, OrganizationId, ProjectId, TeamId, ThreadId, VersionId,
};
use crate::routes::{FileHash, HashAlgorithm};
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use itertools::Itertools;
@@ -970,6 +971,7 @@ pub struct VersionFile {
pub id: Option<FileId>,
/// A map of hashes of the file. The key is the hashing algorithm
/// and the value is the string version of the hash.
#[schema(value_type = std::collections::HashMap<HashAlgorithm, FileHash>)]
pub hashes: std::collections::HashMap<String, String>,
/// A direct link to the file for downloading it.
pub url: String,
+1 -1
View File
@@ -17,7 +17,7 @@ pub struct Report {
pub thread_id: ThreadId,
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ItemType {
Project,
@@ -38,7 +38,7 @@ impl SharedInstance {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SharedInstanceVersion {
pub id: SharedInstanceVersionId,
pub shared_instance: SharedInstanceId,
+2 -2
View File
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
pub const DEFAULT_ROLE: &str = "Member";
/// A team of users who control a project
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct Team {
/// The id of the team
pub id: TeamId,
@@ -157,7 +157,7 @@ impl OrganizationPermissions {
}
/// A member of a team
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct TeamMember {
/// The ID of the team this team member is a member of
pub team_id: TeamId,
+1 -1
View File
@@ -242,7 +242,7 @@ impl Role {
}
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserFriend {
// The user who accepted the friend request
pub id: UserId,
@@ -772,6 +772,7 @@ async fn collect_template_variables(
}
NotificationBody::ProjectUpdate { .. }
| NotificationBody::SharedInstanceInvite { .. }
| NotificationBody::ModeratorMessage { .. }
| NotificationBody::LegacyMarkdown { .. }
| NotificationBody::Unknown => Ok(EmailTemplate::Static(map)),
+73 -7
View File
@@ -5,6 +5,7 @@ use std::sync::Arc;
use chrono::Utc;
use eyre::{Result, eyre};
use hex::ToHex;
use serde::Serialize;
use sha1::Digest;
use tokio::task::{spawn, spawn_blocking};
use tracing::{Instrument, info, info_span, warn};
@@ -42,6 +43,16 @@ struct PendingFileScan {
project_id: DBProjectId,
}
#[derive(Debug, Default, Serialize, utoipa::ToSchema)]
pub struct FileScanSummary {
/// Number of attribution groups newly created by the scan.
pub new_attribution_groups: u64,
/// Number of attribution files newly created by the scan.
pub new_attribution_files: u64,
/// Override file paths found and scanned in the file archive.
pub scanned_file_names: Vec<String>,
}
/// Attribution enforcement is version/project-scoped, not file-hash-scoped.
///
/// Versions or projects listed in `attributions_exemptions` predate this
@@ -277,7 +288,7 @@ pub async fn scan_file(
project_id: DBProjectId,
file_id: DBFileId,
file_url: &str,
) -> Result<()> {
) -> Result<FileScanSummary> {
let result =
scan_file_inner(txn, redis, file_host, project_id, file_id, file_url)
.await;
@@ -296,7 +307,7 @@ async fn scan_file_inner(
project_id: DBProjectId,
file_id: DBFileId,
file_url: &str,
) -> Result<()> {
) -> Result<FileScanSummary> {
let overrides =
extract_override_files_from_storage(file_host, file_id, file_url)
.await
@@ -304,7 +315,16 @@ async fn scan_file_inner(
eyre!("extracting overrides for file {file_id:?}")
})?;
let scanned_file_names =
overrides.iter().map(|file| file.path.clone()).collect();
let mut summary = FileScanSummary {
scanned_file_names,
..Default::default()
};
if !overrides.is_empty() {
let before = count_project_attributions(project_id, txn).await?;
let resolved = resolve_overrides(&overrides, redis, txn)
.await
.wrap_err_with(|| {
@@ -318,15 +338,58 @@ async fn scan_file_inner(
.wrap_err_with(|| {
eyre!("persisting attribution results for file {file_id:?}")
})?;
let after = count_project_attributions(project_id, txn).await?;
summary.new_attribution_groups =
after.groups.saturating_sub(before.groups);
summary.new_attribution_files =
after.files.saturating_sub(before.files);
log_marked_override_projects(&resolved);
}
Ok(())
Ok(summary)
}
fn file_scan_result(result: &Result<()>) -> FileScanResult<'static> {
struct ProjectAttributionCounts {
groups: u64,
files: u64,
}
async fn count_project_attributions(
project_id: DBProjectId,
txn: &mut PgTransaction<'_>,
) -> Result<ProjectAttributionCounts> {
let row = sqlx::query!(
r#"
select
(
select count(*)
from project_attribution_groups
where project_id = $1
) as "groups!",
(
select count(*)
from project_attribution_files paf
inner join project_attribution_groups pag on pag.id = paf.group_id
where pag.project_id = $1
) as "files!"
"#,
project_id as DBProjectId,
)
.fetch_one(&mut *txn)
.await
.wrap_err("counting project attributions")?;
Ok(ProjectAttributionCounts {
groups: row.groups as u64,
files: row.files as u64,
})
}
fn file_scan_result<T>(result: &Result<T>) -> FileScanResult<'static> {
match result {
Ok(()) => Ok(()),
Ok(_) => Ok(()),
Err(err) => Err(ApiError {
error: "internal_error",
description: format!("{err:#}"),
@@ -480,6 +543,7 @@ const OVERRIDE_PREFIXES: &[&str] = &[
];
fn should_scan(name: &str) -> bool {
let name = name.to_lowercase();
let should_skip = name.starts_with("mods/.connector/")
|| name.starts_with(".sable/natives/")
|| name.starts_with("local/crash_assistant/")
@@ -491,8 +555,10 @@ fn should_scan(name: &str) -> bool {
|| name.starts_with("essential/")
|| name.ends_with(".rpo")
|| name.ends_with(".txt");
let is_archive = name.contains(".jar") || name.contains(".zip");
let is_archive = name.ends_with(".jar")
|| name.ends_with(".zip")
|| name.ends_with(".jar.disabled")
|| name.ends_with(".zip.disabled");
is_archive && !should_skip
}
-451
View File
@@ -1,456 +1,5 @@
use crate::database;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::redis::RedisPool;
use crate::database::{PgPool, ReadOnlyPgPool};
use crate::env::ENV;
use crate::models::ids::ProjectId;
use crate::models::notifications::NotificationBody;
use crate::models::projects::ProjectStatus;
use crate::models::threads::MessageBody;
use crate::routes::ApiError;
use dashmap::DashSet;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt::Write;
use std::time::Duration;
pub const AUTOMOD_ID: i64 = 0;
pub struct ModerationMessages {
pub messages: Vec<ModerationMessage>,
pub version_specific: HashMap<String, Vec<ModerationMessage>>,
}
impl ModerationMessages {
pub fn is_empty(&self) -> bool {
self.messages.is_empty() && self.version_specific.is_empty()
}
pub fn markdown(&self, auto_mod: bool) -> String {
let mut str = String::new();
for message in &self.messages {
write!(&mut str, "## {}\n{}\n\n", message.header(), message.body())
.unwrap();
}
for (version_num, messages) in &self.version_specific {
for message in messages {
write!(
&mut str,
"### Version {}: {}\n{}\n\n",
version_num,
message.header(),
message.body()
)
.unwrap();
}
}
if auto_mod {
str.push_str(
"<hr />\n\n\
🤖 This is an automated message generated by AutoMod (BETA). If you are facing issues, please [contact support](https://support.modrinth.com)."
);
}
str
}
pub fn should_reject(&self, first_time: bool) -> bool {
self.messages.iter().any(|x| x.rejectable(first_time))
|| self
.version_specific
.values()
.any(|x| x.iter().any(|x| x.rejectable(first_time)))
}
pub fn approvable(&self) -> bool {
self.messages.iter().all(|x| x.approvable())
&& self
.version_specific
.values()
.all(|x| x.iter().all(|x| x.approvable()))
}
}
pub enum ModerationMessage {
MissingGalleryImage,
NoPrimaryFile,
NoSideTypes,
PackFilesNotAllowed {
files: HashMap<String, IdentifiedFile>,
incomplete: bool,
},
MissingLicense,
MissingCustomLicenseUrl {
license: String,
},
}
impl ModerationMessage {
pub fn rejectable(&self, first_time: bool) -> bool {
match self {
ModerationMessage::NoPrimaryFile => true,
ModerationMessage::PackFilesNotAllowed { files, incomplete } => {
(!incomplete || first_time)
&& files.values().any(|x| match x.status {
ApprovalType::Yes => false,
ApprovalType::WithAttributionAndSource => false,
ApprovalType::WithAttribution => false,
ApprovalType::No => first_time,
ApprovalType::PermanentNo => true,
ApprovalType::Unidentified => first_time,
})
}
ModerationMessage::MissingGalleryImage => true,
ModerationMessage::MissingLicense => true,
ModerationMessage::MissingCustomLicenseUrl { .. } => true,
ModerationMessage::NoSideTypes => true,
}
}
pub fn approvable(&self) -> bool {
match self {
ModerationMessage::NoPrimaryFile => false,
ModerationMessage::PackFilesNotAllowed { files, .. } => {
files.values().all(|x| x.status.approved())
}
ModerationMessage::MissingGalleryImage => false,
ModerationMessage::MissingLicense => false,
ModerationMessage::MissingCustomLicenseUrl { .. } => false,
ModerationMessage::NoSideTypes => false,
}
}
pub fn header(&self) -> &'static str {
match self {
ModerationMessage::NoPrimaryFile => "No primary files",
ModerationMessage::PackFilesNotAllowed { .. } => {
"Copyrighted Content"
}
ModerationMessage::MissingGalleryImage => "Missing Gallery Images",
ModerationMessage::MissingLicense => "Missing License",
ModerationMessage::MissingCustomLicenseUrl { .. } => {
"Missing License URL"
}
ModerationMessage::NoSideTypes => "Missing Environment Information",
}
}
pub fn body(&self) -> String {
match self {
ModerationMessage::NoPrimaryFile => "Please attach a file to this version. All files on Modrinth must have files associated with their versions.\n".to_string(),
ModerationMessage::PackFilesNotAllowed { files, .. } => {
let mut str = String::from("This pack redistributes copyrighted material. Please refer to [Modrinth's guide on obtaining modpack permissions](https://support.modrinth.com/en/articles/8797527-obtaining-modpack-permissions) for more information.\n\n");
let mut attribute_mods = Vec::new();
let mut no_mods = Vec::new();
let mut permanent_no_mods = Vec::new();
let mut unidentified_mods = Vec::new();
for approval in files.values() {
match approval.status {
ApprovalType::Yes | ApprovalType::WithAttributionAndSource => {}
ApprovalType::WithAttribution => attribute_mods.push(&approval.file_name),
ApprovalType::No => no_mods.push(&approval.file_name),
ApprovalType::PermanentNo => permanent_no_mods.push(&approval.file_name),
ApprovalType::Unidentified => unidentified_mods.push(&approval.file_name),
}
}
fn print_mods(projects: Vec<&String>, headline: &str, val: &mut String) {
if projects.is_empty() { return }
write!(val, "{headline}\n\n").unwrap();
for project in &projects {
let additional_text = if project.contains("ftb-quests") {
Some(("Odyssey Quests", "lo90fZoB"))
} else if project.contains("ftb-ranks") || project.contains("ftb-essentials") {
Some(("Odyssey Roles", "iYcNKH7W"))
} else if project.contains("ftb-teams") {
Some(("Odyssey Guilds", "bb2EpKpx"))
} else if project.contains("ftb-chunks") {
Some(("Odyssey Claims", "fEWKxVzh"))
} else {
None
};
if let Some(additional_text) = additional_text {
writeln!(val, "- {project} (consider using [{}](https://modrinth.com/project/{}) instead)", additional_text.0, additional_text.1).unwrap();
} else {
writeln!(val, "- {project}").unwrap();
}
}
if !projects.is_empty() {
val.push('\n');
}
}
print_mods(attribute_mods, "The following content has attribution requirements, meaning that you must link back to the page where you originally found this content in your modpack description or version changelog (e.g. linking a mod's CurseForge page if you got it from CurseForge):", &mut str);
print_mods(no_mods, "The following content is not allowed in Modrinth modpacks due to licensing restrictions. Please contact the author(s) directly for permission or remove the content from your modpack:", &mut str);
print_mods(permanent_no_mods, "The following content is not allowed in Modrinth modpacks, regardless of permission obtained. This may be because it breaks Modrinth's content rules or because the authors, upon being contacted for permission, have declined. Please remove the content from your modpack:", &mut str);
print_mods(unidentified_mods, "The following content could not be identified. Please provide proof of its origin along with proof that you have permission to include it:", &mut str);
str
},
ModerationMessage::MissingGalleryImage => "We ask that resource packs like yours show off their content using images in the Gallery, or optionally in the Description, in order to effectively and clearly inform users of the content in your pack per section 2.1 of [Modrinth's content rules](https://modrinth.com/legal/rules#general-expectations).\n
Keep in mind that you should:\n
- Set a featured image that best represents your pack.
- Ensure all your images have titles that accurately label the image, and optionally, details on the contents of the image in the images Description.
- Upload any relevant images in your Description to your Gallery tab for best results.".to_string(),
ModerationMessage::MissingLicense => "You must select a License before your project can be published publicly, having a License associated with your project is important to protecting your rights and allowing others to use your content as you intend. For more information, you can see our [Guide to Licensing Mods](<https://modrinth.com/news/article/licensing-guide/>).".to_string(),
ModerationMessage::MissingCustomLicenseUrl { license } => format!("It looks like you've selected the License \"{license}\" without providing a valid License link. When using a custom License you must provide a link directly to the License in the License Link field."),
ModerationMessage::NoSideTypes => "Your project's side types are currently set to Unknown on both sides. Please set accurate side types!".to_string(),
}
}
}
pub struct AutomatedModerationQueue {
pub projects: DashSet<ProjectId>,
}
impl Default for AutomatedModerationQueue {
fn default() -> Self {
Self {
projects: DashSet::new(),
}
}
}
impl AutomatedModerationQueue {
pub async fn task(
&self,
pool: PgPool,
ro_pool: ReadOnlyPgPool,
redis: RedisPool,
) {
loop {
let projects = self.projects.clone();
self.projects.clear();
for project in projects {
async {
let project =
database::DBProject::get_id((project).into(), &*ro_pool, &redis)
.await?;
if let Some(project) = project {
let res = async {
let mut mod_messages = ModerationMessages {
messages: vec![],
version_specific: HashMap::new(),
};
if project.project_types.iter().any(|x| ["mod", "modpack"].contains(&&**x)) && !project.aggregate_version_fields.iter().any(|x| x.field_name == "environment") {
mod_messages.messages.push(ModerationMessage::NoSideTypes);
}
if project.inner.components.minecraft_server.is_none() {
let license = &project.inner.license;
if license == "LicenseRef-Unknown" || license == "LicenseRef-" {
mod_messages
.messages
.push(ModerationMessage::MissingLicense);
} else if license.starts_with("LicenseRef-")
&& license != "LicenseRef-All-Rights-Reserved"
&& project.inner.license_url.is_none()
{
mod_messages.messages.push(
ModerationMessage::MissingCustomLicenseUrl {
license: project.inner.license.clone(),
},
);
}
}
if (project.project_types.contains(&"resourcepack".to_string()) || project.project_types.contains(&"shader".to_string())) &&
project.gallery_items.is_empty() &&
!project.categories.contains(&"audio".to_string()) &&
!project.categories.contains(&"locale".to_string())
{
mod_messages.messages.push(ModerationMessage::MissingGalleryImage);
}
let versions =
database::DBVersion::get_many(&project.versions, &*ro_pool, &redis)
.await?
.into_iter()
// we only support modpacks at this time
.filter(|x| x.project_types.contains(&"modpack".to_string()))
.collect::<Vec<_>>();
for version in versions {
let primary_file = version.files.iter().find_or_first(|x| x.primary);
if primary_file.is_none() {
let val = mod_messages.version_specific.entry(version.inner.version_number).or_default();
val.push(ModerationMessage::NoPrimaryFile);
}
}
if !mod_messages.is_empty() {
let is_server_project =
project.inner.components.minecraft_server.is_some();
let first_time = database::models::DBThread::get(project.thread_id, &pool).await?
.is_none_or(|x| x.messages.iter().all(|x| x.author_id == Some(database::models::DBUserId(AUTOMOD_ID)) || x.hide_identity));
let mut transaction = pool.begin().await?;
let id = ThreadMessageBuilder {
author_id: Some(database::models::DBUserId(AUTOMOD_ID)),
body: MessageBody::Text {
body: mod_messages.markdown(true),
private: is_server_project,
replying_to: None,
associated_images: vec![],
},
thread_id: project.thread_id,
hide_identity: false,
}
.insert(&mut transaction)
.await?;
let members = database::models::DBTeamMember::get_from_team_full(
project.inner.team_id,
&pool,
&redis,
)
.await?;
if mod_messages.should_reject(first_time) && !is_server_project {
ThreadMessageBuilder {
author_id: Some(database::models::DBUserId(AUTOMOD_ID)),
body: MessageBody::StatusChange {
new_status: ProjectStatus::Rejected,
old_status: project.inner.status,
},
thread_id: project.thread_id,
hide_identity: false,
}
.insert(&mut transaction)
.await?;
NotificationBuilder {
body: NotificationBody::StatusChange {
project_id: project.inner.id.into(),
old_status: project.inner.status,
new_status: ProjectStatus::Rejected,
},
}
.insert_many(members.into_iter().map(|x| x.user_id).collect(), &mut transaction, &redis)
.await?;
if !ENV.MODERATION_SLACK_WEBHOOK.is_empty() {
crate::util::webhook::send_slack_project_webhook(
project.inner.id.into(),
&pool,
&redis,
&ENV.MODERATION_SLACK_WEBHOOK,
Some(
format!(
"*<{}/user/AutoMod|AutoMod>* changed project status from *{}* to *Rejected*",
ENV.SITE_URL,
&project.inner.status.as_friendly_str(),
)
.to_string(),
),
)
.await
.ok();
}
sqlx::query!(
"
UPDATE mods
SET status = 'rejected'
WHERE id = $1
",
project.inner.id.0
)
.execute(&pool)
.await?;
database::models::DBProject::clear_cache(
project.inner.id,
project.inner.slug.clone(),
None,
&redis,
)
.await?;
} else {
NotificationBuilder {
body: NotificationBody::ModeratorMessage {
thread_id: project.thread_id.into(),
message_id: id.into(),
project_id: Some(project.inner.id.into()),
report_id: None,
},
}
.insert_many(
members.iter().map(|x| x.user_id).collect(),
&mut transaction,
&redis,
)
.await?;
NotificationBuilder {
body: NotificationBody::ModerationMessageReceived {
project_id: project.inner.id.into(),
},
}
.insert_many(
members.iter().map(|x| x.user_id).collect(),
&mut transaction,
&redis,
)
.await?;
}
transaction.commit().await?;
}
Ok::<(), ApiError>(())
}.await;
if let Err(err) = res {
let err = err.as_api_error();
let str = format!(
"## Internal AutoMod Error\n\n\
Error code: {}\n\n\
Error description: {}\n\n",
err.error, err.description
);
let mut transaction = pool.begin().await?;
ThreadMessageBuilder {
author_id: Some(database::models::DBUserId(AUTOMOD_ID)),
body: MessageBody::Text {
body: str,
private: true,
replying_to: None,
associated_images: vec![],
},
thread_id: project.thread_id,
hide_identity: false,
}
.insert(&mut transaction)
.await?;
transaction.commit().await?;
}
}
Ok::<(), ApiError>(())
}.await.ok();
}
tokio::time::sleep(Duration::from_secs(5)).await
}
}
}
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MissingMetadata {
+28 -10
View File
@@ -46,20 +46,26 @@ pub const FILTERED_HEADERS: &[&str] = &[
"x-vercel-ip-country",
];
pub fn config(cfg: &mut web::ServiceConfig) {
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(page_view_ingest)
.service(playtime_ingest)
.service(minecraft_server_play_ingest);
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct UrlInput {
url: String,
}
//this route should be behind the cloudflare WAF to prevent non-browsers from calling it
#[post("view")]
async fn page_view_ingest(
#[utoipa::path(
context_path = "/analytics",
tag = "analytics",
request_body = UrlInput,
responses((status = NO_CONTENT))
)]
#[post("/view")]
pub async fn page_view_ingest(
req: HttpRequest,
analytics_queue: web::Data<Arc<AnalyticsQueue>>,
session_queue: web::Data<AuthQueue>,
@@ -171,7 +177,7 @@ async fn page_view_ingest(
Ok(HttpResponse::NoContent().body(""))
}
#[derive(Deserialize, Debug)]
#[derive(Deserialize, Debug, utoipa::ToSchema)]
pub struct PlaytimeInput {
seconds: u16,
loader: String,
@@ -179,8 +185,14 @@ pub struct PlaytimeInput {
parent: Option<crate::models::ids::VersionId>,
}
#[post("playtime")]
async fn playtime_ingest(
#[utoipa::path(
context_path = "/analytics",
tag = "analytics",
request_body = serde_json::Value,
responses((status = NO_CONTENT))
)]
#[post("/playtime")]
pub async fn playtime_ingest(
req: HttpRequest,
analytics_queue: web::Data<Arc<AnalyticsQueue>>,
session_queue: web::Data<AuthQueue>,
@@ -249,7 +261,7 @@ struct MinecraftProfile {
name: String,
}
#[derive(Deserialize)]
#[derive(Deserialize, utoipa::ToSchema)]
pub struct MinecraftJavaServerPlayInput {
project_id: ProjectId,
username: String,
@@ -258,8 +270,14 @@ pub struct MinecraftJavaServerPlayInput {
pub const MINECRAFT_SERVER_PLAYS: &str = "minecraft_server_plays";
#[post("minecraft-server-play")]
async fn minecraft_server_play_ingest(
#[utoipa::path(
context_path = "/analytics",
tag = "analytics",
request_body = MinecraftJavaServerPlayInput,
responses((status = NO_CONTENT))
)]
#[post("/minecraft-server-play")]
pub async fn minecraft_server_play_ingest(
req: HttpRequest,
analytics_queue: web::Data<Arc<AnalyticsQueue>>,
session_queue: web::Data<AuthQueue>,
+33 -16
View File
@@ -1,29 +1,46 @@
use std::time::Duration;
use actix_web::web;
use eyre::Context;
use eyre::eyre;
use prometheus::IntGauge;
#[cfg(target_os = "linux")]
use super::SecurityAddon;
use crate::util::cors::default_cors;
#[cfg(target_os = "linux")]
mod pprof;
pub(crate) mod pprof;
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(
utoipa_actix_web::scope("/debug")
.wrap(default_cors())
.configure({
#[cfg(target_os = "linux")]
{
pprof::config
}
#[cfg(not(target_os = "linux"))]
{
|_cfg| ()
}
}),
);
#[cfg(target_os = "linux")]
#[derive(utoipa::OpenApi)]
#[openapi(
paths(pprof::heap, pprof::flame_graph),
modifiers(&DebugPathModifier, &SecurityAddon)
)]
pub struct ApiDoc;
#[cfg(target_os = "linux")]
struct DebugPathModifier;
#[cfg(target_os = "linux")]
impl utoipa::Modify for DebugPathModifier {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
super::prefix_openapi_paths(openapi, "/debug", |_| false);
}
}
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(web::scope("/debug").wrap(default_cors()).configure({
#[cfg(target_os = "linux")]
{
pprof::config
}
#[cfg(not(target_os = "linux"))]
{
|_cfg| ()
}
}));
}
pub fn register_and_set_metrics(

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