mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3c7c99c4f | ||
|
|
d1ec42702b | ||
|
|
f3ef416d64 | ||
|
|
fc7be043c7 | ||
|
|
7fa88e5d4d | ||
|
|
21508e0637 | ||
|
|
ad4912d64d | ||
|
|
0002ae2479 | ||
|
|
e4568330a7 | ||
|
|
b0108e0624 | ||
|
|
ac63abadb7 | ||
|
|
fa749b5aff | ||
|
|
a2adbe18c4 | ||
|
|
0fcbe51816 | ||
|
|
23cfeca91d | ||
|
|
db830ef65c | ||
|
|
08f44b3582 | ||
|
|
8ffc4e2599 | ||
|
|
58a5619073 | ||
|
|
4a6fa9fc3d |
@@ -79,6 +79,7 @@ jobs:
|
||||
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
|
||||
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
|
||||
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
|
||||
@@ -93,10 +94,11 @@ jobs:
|
||||
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
|
||||
|
||||
- name: Install build dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
with:
|
||||
packages: cmake libcurl4-openssl-dev
|
||||
version: v2 # cache key
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake libcurl4-openssl-dev
|
||||
#uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
#with:
|
||||
# packages: cmake libcurl4-openssl-dev
|
||||
# version: v2 # cache key
|
||||
|
||||
- name: Cache Cargo registry and index
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
|
||||
Generated
+147
-64
@@ -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
@@ -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"
|
||||
|
||||
@@ -14,31 +14,7 @@
|
||||
<UnplugIcon class="text-secondary" />
|
||||
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
|
||||
</div>
|
||||
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
|
||||
<button
|
||||
v-if="showUpdatePill"
|
||||
type="button"
|
||||
class="!h-[34px] overflow-hidden text-sm !transition-[width,opacity,transform,background-color,color,filter] !duration-200 ease-out"
|
||||
:class="[
|
||||
updatePillWidthClass,
|
||||
{
|
||||
'update-pill-ready-hidden': finishedDownloading && !animateReadyPill,
|
||||
'update-pill-ready-visible': finishedDownloading && animateReadyPill,
|
||||
},
|
||||
]"
|
||||
:disabled="isUpdateDownloading"
|
||||
:aria-busy="isUpdateDownloading"
|
||||
@click="handleUpdateClick"
|
||||
>
|
||||
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
|
||||
<DownloadIcon v-else />
|
||||
<span v-if="isUpdateDownloading">
|
||||
{{ formatMessage(messages.downloadingUpdate) }}
|
||||
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
|
||||
</span>
|
||||
<span v-else>{{ updateLabel }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<AppUpdateButton />
|
||||
<div
|
||||
class="flex border-solid border-surface-5 text-sm items-center gap-2 py-1.5 px-3 rounded-xl border"
|
||||
>
|
||||
@@ -144,7 +120,6 @@ import {
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
OnlineIndicatorIcon,
|
||||
RefreshCwIcon,
|
||||
StarIcon,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
@@ -161,9 +136,10 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
|
||||
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { loading_listener, process_listener } from '@/helpers/events'
|
||||
@@ -172,11 +148,6 @@ import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/p
|
||||
import type { LoadingBar } from '@/helpers/state'
|
||||
import { progress_bars_list } from '@/helpers/state'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
appUpdateState,
|
||||
downloadAvailableAppUpdate,
|
||||
installAvailableAppUpdate,
|
||||
} from '@/providers/app-update'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const popupNotificationManager = injectPopupNotificationManager()
|
||||
@@ -241,96 +212,8 @@ const messages = defineMessages({
|
||||
id: 'app.action-bar.view-active-downloads',
|
||||
defaultMessage: 'View active downloads',
|
||||
},
|
||||
update: {
|
||||
id: 'app.action-bar.update',
|
||||
defaultMessage: 'Update',
|
||||
},
|
||||
downloadingUpdate: {
|
||||
id: 'app.action-bar.downloading-update',
|
||||
defaultMessage: 'Downloading update',
|
||||
},
|
||||
reloadToUpdate: {
|
||||
id: 'app.action-bar.reload-to-update',
|
||||
defaultMessage: 'Reload to update',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
downloading,
|
||||
downloadPercent,
|
||||
downloadProgress,
|
||||
finishedDownloading,
|
||||
isVisible: isUpdateVisible,
|
||||
metered,
|
||||
restarting,
|
||||
} = appUpdateState
|
||||
|
||||
const isUpdateDownloading = computed(
|
||||
() =>
|
||||
downloading.value ||
|
||||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
|
||||
)
|
||||
const showUpdatePill = computed(
|
||||
() => isUpdateVisible.value && (finishedDownloading.value || metered.value),
|
||||
)
|
||||
const animateReadyPill = ref(false)
|
||||
const updateLabel = computed(() => {
|
||||
if (isUpdateDownloading.value) {
|
||||
return formatMessage(messages.downloadingUpdate)
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
return formatMessage(messages.reloadToUpdate)
|
||||
}
|
||||
|
||||
return formatMessage(messages.update)
|
||||
})
|
||||
const updatePillWidthClass = computed(() => {
|
||||
if (isUpdateDownloading.value) {
|
||||
return 'w-[219px]'
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
return 'w-[166px]'
|
||||
}
|
||||
|
||||
return '!w-[96px]'
|
||||
})
|
||||
let readyPillAnimationFrame: number | null = null
|
||||
watch([showUpdatePill, finishedDownloading], async ([show, ready], [wasShown, wasReady]) => {
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
readyPillAnimationFrame = null
|
||||
}
|
||||
|
||||
if (!show || !ready) {
|
||||
animateReadyPill.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (wasShown && wasReady) {
|
||||
return
|
||||
}
|
||||
|
||||
animateReadyPill.value = false
|
||||
await nextTick()
|
||||
readyPillAnimationFrame = requestAnimationFrame(() => {
|
||||
animateReadyPill.value = true
|
||||
readyPillAnimationFrame = null
|
||||
})
|
||||
})
|
||||
async function handleUpdateClick() {
|
||||
if (isUpdateDownloading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
await installAvailableAppUpdate()
|
||||
} else {
|
||||
await downloadAvailableAppUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
const currentProcesses = ref<RunningProcess[]>([])
|
||||
const selectedProcess = ref<RunningProcess | undefined>()
|
||||
|
||||
@@ -619,20 +502,5 @@ onBeforeUnmount(() => {
|
||||
unlistenProcess()
|
||||
unlistenLoading()
|
||||
installJobNotifications.dispose()
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.update-pill-ready-hidden {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.update-pill-ready-visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
|
||||
<button
|
||||
v-if="showUpdatePill"
|
||||
type="button"
|
||||
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out"
|
||||
:class="{
|
||||
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
|
||||
'opacity-100 scale-100': finishedDownloading && animateReadyPill,
|
||||
}"
|
||||
:disabled="isUpdateDownloading"
|
||||
:aria-busy="isUpdateDownloading"
|
||||
@click="handleUpdateClick"
|
||||
>
|
||||
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
|
||||
<DownloadIcon v-else />
|
||||
<span v-if="isUpdateDownloading">
|
||||
{{ formatMessage(messages.downloadingUpdate) }}
|
||||
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
|
||||
</span>
|
||||
<span v-else>{{ updateLabel }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, RefreshCwIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
appUpdateState,
|
||||
downloadAvailableAppUpdate,
|
||||
installAvailableAppUpdate,
|
||||
} from '@/providers/app-update'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
update: {
|
||||
id: 'app.action-bar.update',
|
||||
defaultMessage: 'Update',
|
||||
},
|
||||
downloadingUpdate: {
|
||||
id: 'app.action-bar.downloading-update',
|
||||
defaultMessage: 'Downloading update',
|
||||
},
|
||||
reloadToUpdate: {
|
||||
id: 'app.action-bar.reload-to-update',
|
||||
defaultMessage: 'Reload to update',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
downloading,
|
||||
downloadPercent,
|
||||
downloadProgress,
|
||||
finishedDownloading,
|
||||
isVisible: isUpdateVisible,
|
||||
metered,
|
||||
restarting,
|
||||
} = appUpdateState
|
||||
|
||||
const isUpdateDownloading = computed(
|
||||
() =>
|
||||
downloading.value ||
|
||||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
|
||||
)
|
||||
const showUpdatePill = computed(
|
||||
() => isUpdateVisible.value && (finishedDownloading.value || metered.value),
|
||||
)
|
||||
const animateReadyPill = ref(false)
|
||||
const updateLabel = computed(() => {
|
||||
if (isUpdateDownloading.value) {
|
||||
return formatMessage(messages.downloadingUpdate)
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
return formatMessage(messages.reloadToUpdate)
|
||||
}
|
||||
|
||||
return formatMessage(messages.update)
|
||||
})
|
||||
let readyPillAnimationFrame: number | null = null
|
||||
watch([showUpdatePill, finishedDownloading], async ([show, ready], [wasShown, wasReady]) => {
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
readyPillAnimationFrame = null
|
||||
}
|
||||
|
||||
if (!show || !ready) {
|
||||
animateReadyPill.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (wasShown && wasReady) {
|
||||
return
|
||||
}
|
||||
|
||||
animateReadyPill.value = false
|
||||
await nextTick()
|
||||
readyPillAnimationFrame = requestAnimationFrame(() => {
|
||||
animateReadyPill.value = true
|
||||
readyPillAnimationFrame = null
|
||||
})
|
||||
})
|
||||
async function handleUpdateClick() {
|
||||
if (isUpdateDownloading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
await installAvailableAppUpdate()
|
||||
} else {
|
||||
await downloadAvailableAppUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -180,7 +180,7 @@
|
||||
"message": "Discover servers"
|
||||
},
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "Hide already added servers"
|
||||
"message": "Hide servers already added"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpacks"
|
||||
|
||||
@@ -403,7 +403,7 @@ const messages = defineMessages({
|
||||
},
|
||||
hideAddedServers: {
|
||||
id: 'app.browse.hide-added-servers',
|
||||
defaultMessage: 'Hide already added servers',
|
||||
defaultMessage: 'Hide servers already added',
|
||||
},
|
||||
installingToServer: {
|
||||
id: 'app.browse.server.installing',
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
<template>
|
||||
<div v-if="downloadRows.length > 0" class="flex flex-col gap-1">
|
||||
<div v-if="showTitle" class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="m-0 flex items-center gap-1.5 text-base font-semibold text-contrast">
|
||||
{{ sectionTitle }}
|
||||
<InfoIcon
|
||||
v-if="duplicateDependencyRowsHidden"
|
||||
v-tooltip="formatMessage(messages.duplicateDependenciesHidden)"
|
||||
aria-hidden="true"
|
||||
class="size-4 text-secondary"
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DownloadDependency
|
||||
v-for="dependency in downloadRows"
|
||||
:key="dependency.key"
|
||||
:dependency="dependency"
|
||||
@download="emit('download')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { FileIcon, InfoIcon } from '@modrinth/assets'
|
||||
import {
|
||||
type CdnDownloadReason,
|
||||
defineMessages,
|
||||
fileTypeMessages,
|
||||
injectModrinthClient,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { type Component, computed, watch } from 'vue'
|
||||
|
||||
import DownloadDependency from './DownloadDependency.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'DownloadDependencies',
|
||||
})
|
||||
|
||||
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
|
||||
project_type: DisplayProjectType
|
||||
actualProjectType: Labrinth.Projects.v2.ProjectType
|
||||
}
|
||||
|
||||
type ResolvedContent = Labrinth.Content.v3.ResolvedContent | Labrinth.Content.v3.SkippedContent
|
||||
|
||||
interface DownloadDependencyRow {
|
||||
key: string
|
||||
name: string
|
||||
icon?: string
|
||||
fallbackIcon?: Component
|
||||
projectHref?: string
|
||||
downloadHref?: string
|
||||
filename?: string
|
||||
fileSize?: number
|
||||
typeLabel: string
|
||||
unavailableTooltip: string
|
||||
dependencies: DownloadDependencyRow[]
|
||||
}
|
||||
|
||||
interface DownloadableDependencyFile {
|
||||
href: string
|
||||
filename: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
dependencies?: DownloadDependencyRow[] | null
|
||||
project?: DownloadModalProject | null
|
||||
selectedVersion?: Labrinth.Versions.v3.Version | null
|
||||
currentGameVersion?: string | null
|
||||
currentPlatform?: string | null
|
||||
downloadReason?: CdnDownloadReason
|
||||
additionalFiles?: Labrinth.Versions.v3.VersionFile[]
|
||||
showTitle?: boolean
|
||||
}>(),
|
||||
{
|
||||
dependencies: null,
|
||||
project: null,
|
||||
selectedVersion: null,
|
||||
currentGameVersion: null,
|
||||
currentPlatform: null,
|
||||
downloadReason: 'standalone',
|
||||
additionalFiles: () => [],
|
||||
showTitle: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: []
|
||||
'update:downloadable-files': [files: DownloadableDependencyFile[]]
|
||||
}>()
|
||||
const client = injectModrinthClient()
|
||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const shouldResolveDependencies = computed(
|
||||
() => !props.dependencies && !!props.project && !!props.selectedVersion,
|
||||
)
|
||||
|
||||
const dependencyResolutionPreferences = computed<Labrinth.Content.v3.ResolutionPreferences>(() => ({
|
||||
game_versions: props.selectedVersion?.game_versions || [],
|
||||
loaders: props.currentPlatform ? [props.currentPlatform] : props.selectedVersion?.loaders || [],
|
||||
}))
|
||||
|
||||
const { data: dependencyResolution } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'project-download-modal',
|
||||
'content-resolve',
|
||||
props.project?.id,
|
||||
props.selectedVersion?.id,
|
||||
props.project?.project_type,
|
||||
dependencyResolutionPreferences.value,
|
||||
]),
|
||||
queryFn: () =>
|
||||
client.labrinth.content_v3.resolve({
|
||||
project_id: props.project!.id,
|
||||
version_id: props.selectedVersion!.id,
|
||||
content_type: resolveContentType(props.project!.project_type),
|
||||
selected: dependencyResolutionPreferences.value,
|
||||
target: dependencyResolutionPreferences.value,
|
||||
}),
|
||||
enabled: shouldResolveDependencies,
|
||||
})
|
||||
|
||||
const visibleResolvedDependencies = computed<ResolvedContent[]>(() => {
|
||||
return [
|
||||
...(dependencyResolution.value?.dependencies || []),
|
||||
...(dependencyResolution.value?.skipped || []),
|
||||
].filter(shouldShowDependency)
|
||||
})
|
||||
|
||||
const dependencyVersionIds = computed<string[]>(() => {
|
||||
return [
|
||||
...new Set(
|
||||
visibleResolvedDependencies.value
|
||||
.filter((dependency) => !('reason' in dependency))
|
||||
.map((dependency) => dependency.version_id)
|
||||
.filter((versionId): versionId is string => !!versionId),
|
||||
),
|
||||
]
|
||||
})
|
||||
|
||||
const { data: dependencyVersions } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'project-download-modal',
|
||||
'resolved-versions',
|
||||
dependencyVersionIds.value,
|
||||
]),
|
||||
queryFn: () => client.labrinth.versions_v3.getVersions(dependencyVersionIds.value),
|
||||
enabled: computed(() => shouldResolveDependencies.value && dependencyVersionIds.value.length > 0),
|
||||
})
|
||||
|
||||
const dependencyVersionById = computed(() => {
|
||||
const map = new Map<string, Labrinth.Versions.v3.Version>()
|
||||
for (const version of dependencyVersions.value || []) {
|
||||
if (!version) continue
|
||||
map.set(version.id, version)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const dependencyProjectIds = computed<string[]>(() => {
|
||||
return [
|
||||
...new Set(
|
||||
visibleResolvedDependencies.value
|
||||
.map((dependency) => dependency.project_id)
|
||||
.filter((projectId): projectId is string => !!projectId),
|
||||
),
|
||||
]
|
||||
})
|
||||
|
||||
const { data: dependencyProjects } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'project-download-modal',
|
||||
'resolved-projects',
|
||||
dependencyProjectIds.value,
|
||||
]),
|
||||
queryFn: () => client.labrinth.projects_v2.getMultiple(dependencyProjectIds.value),
|
||||
enabled: computed(() => shouldResolveDependencies.value && dependencyProjectIds.value.length > 0),
|
||||
})
|
||||
|
||||
const dependencyProjectById = computed(() => {
|
||||
const map = new Map<string, Labrinth.Projects.v2.Project>()
|
||||
for (const project of dependencyProjects.value || []) {
|
||||
map.set(project.id, project)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const dependenciesByParentVersionId = computed(() => {
|
||||
const map = new Map<string, ResolvedContent[]>()
|
||||
|
||||
for (const dependency of visibleResolvedDependencies.value) {
|
||||
if (!dependency.dependent_on_version_id) continue
|
||||
|
||||
const dependencies = map.get(dependency.dependent_on_version_id) || []
|
||||
dependencies.push(dependency)
|
||||
map.set(dependency.dependent_on_version_id, dependencies)
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const dependenciesLoaded = computed(() => {
|
||||
if (!shouldResolveDependencies.value) return false
|
||||
if (!dependencyResolution.value) return false
|
||||
if (
|
||||
dependencyResolution.value.primary.version_id &&
|
||||
dependencyResolution.value.primary.version_id !== props.selectedVersion?.id
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!dependencyVersionIds.value.every((versionId) => dependencyVersionById.value.has(versionId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!dependencyProjectIds.value.every((projectId) => dependencyProjectById.value.has(projectId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const resolvedDependencyRows = computed<DownloadDependencyRow[]>(() => {
|
||||
if (!dependenciesLoaded.value) return []
|
||||
|
||||
const primaryVersionId =
|
||||
dependencyResolution.value?.primary.version_id || props.selectedVersion?.id
|
||||
if (!primaryVersionId) return []
|
||||
|
||||
const dependencies = dependenciesByParentVersionId.value.get(primaryVersionId) || []
|
||||
|
||||
return dependencies.flatMap((dependency) => {
|
||||
const row = createDependencyRow(dependency)
|
||||
return row ? [row] : []
|
||||
})
|
||||
})
|
||||
|
||||
const dependencyRows = computed<DownloadDependencyRow[]>(
|
||||
() => props.dependencies || resolvedDependencyRows.value,
|
||||
)
|
||||
|
||||
const visibleDependencyRows = computed<DownloadDependencyRow[]>(() =>
|
||||
dedupeDependencyRows(dependencyRows.value),
|
||||
)
|
||||
|
||||
const duplicateDependencyRowsHidden = computed(() =>
|
||||
hasDuplicateDependencyRows(dependencyRows.value),
|
||||
)
|
||||
|
||||
const additionalFileRows = computed<DownloadDependencyRow[]>(() =>
|
||||
props.additionalFiles.map((file) => ({
|
||||
key: `additional-file-${additionalFileKey(file)}`,
|
||||
name: file.filename,
|
||||
fallbackIcon: FileIcon,
|
||||
downloadHref: getDownloadUrl(file.url),
|
||||
filename: file.filename,
|
||||
fileSize: file.size,
|
||||
typeLabel: fileTypeLabel(file.file_type),
|
||||
unavailableTooltip: formatMessage(messages.unavailableFile),
|
||||
dependencies: [],
|
||||
})),
|
||||
)
|
||||
|
||||
const downloadRows = computed<DownloadDependencyRow[]>(() => [
|
||||
...visibleDependencyRows.value,
|
||||
...additionalFileRows.value,
|
||||
])
|
||||
|
||||
const sectionTitle = computed(() =>
|
||||
formatMessage(
|
||||
visibleDependencyRows.value.length > 0
|
||||
? messages.dependenciesTitle
|
||||
: messages.additionalFilesTitle,
|
||||
),
|
||||
)
|
||||
|
||||
const downloadableDependencyFiles = computed<DownloadableDependencyFile[]>(() =>
|
||||
collectDownloadableDependencyFiles(visibleDependencyRows.value),
|
||||
)
|
||||
|
||||
watch(
|
||||
downloadableDependencyFiles,
|
||||
(files) => {
|
||||
emit('update:downloadable-files', files)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function primaryFileForVersion(version?: Labrinth.Versions.v3.Version) {
|
||||
return version?.files?.find((file) => file.primary) || version?.files?.[0]
|
||||
}
|
||||
|
||||
function shouldShowDependency(dependency: ResolvedContent) {
|
||||
return !(
|
||||
'reason' in dependency && ['duplicate_project', 'quilt_fabric_api'].includes(dependency.reason)
|
||||
)
|
||||
}
|
||||
|
||||
function createDependencyRow(dependency: ResolvedContent): DownloadDependencyRow | null {
|
||||
const versionId = dependency.version_id ?? undefined
|
||||
const version = versionId ? dependencyVersionById.value.get(versionId) : undefined
|
||||
const project = dependencyProjectById.value.get(dependency.project_id)
|
||||
if (!project) return null
|
||||
|
||||
const primaryFile = primaryFileForVersion(version)
|
||||
const unavailableTooltip =
|
||||
'reason' in dependency && dependency.reason
|
||||
? skippedReasonLabel(dependency.reason)
|
||||
: formatMessage(messages.unavailableDependency)
|
||||
const name = project.title
|
||||
|
||||
return {
|
||||
key: `${dependency.project_id}-${versionId ?? 'unresolved'}-${
|
||||
'reason' in dependency ? dependency.reason : 'resolved'
|
||||
}`,
|
||||
name,
|
||||
icon: project.icon_url ?? undefined,
|
||||
projectHref: `/${project.project_type}/${project.slug || project.id}`,
|
||||
downloadHref:
|
||||
'reason' in dependency || !primaryFile ? undefined : getDownloadUrl(primaryFile.url),
|
||||
filename: primaryFile?.filename,
|
||||
fileSize: primaryFile?.size,
|
||||
typeLabel: 'Required',
|
||||
unavailableTooltip,
|
||||
dependencies: (versionId && dependenciesByParentVersionId.value.get(versionId)
|
||||
? dependenciesByParentVersionId.value.get(versionId)!
|
||||
: []
|
||||
).flatMap((subDependency) => {
|
||||
const row = createDependencyRow(subDependency)
|
||||
return row ? [row] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function skippedReasonLabel(reason: Labrinth.Content.v3.SkippedContent['reason']) {
|
||||
return (
|
||||
{
|
||||
already_installed: formatMessage(messages.alreadyInstalledDependency),
|
||||
duplicate_project: formatMessage(messages.duplicateDependency),
|
||||
conflicting_dependency: formatMessage(messages.conflictingDependency),
|
||||
no_compatible_version: formatMessage(messages.noCompatibleDependency),
|
||||
missing_version: formatMessage(messages.missingDependencyVersion),
|
||||
quilt_fabric_api: formatMessage(messages.quiltFabricApiDependency),
|
||||
}[reason] || formatMessage(messages.unavailableDependency)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveContentType(projectType: DisplayProjectType): Labrinth.Content.v3.ContentType {
|
||||
return ['mod', 'plugin', 'datapack', 'resourcepack', 'shader', 'modpack'].includes(projectType)
|
||||
? (projectType as Labrinth.Content.v3.ContentType)
|
||||
: 'mod'
|
||||
}
|
||||
|
||||
function getDownloadUrl(url: string) {
|
||||
return createProjectDownloadUrl(url, {
|
||||
reason: props.downloadReason,
|
||||
gameVersion: props.currentGameVersion ?? undefined,
|
||||
loader: props.currentPlatform ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function fileTypeLabel(type?: Labrinth.Versions.v3.FileType | null) {
|
||||
return formatMessage(fileTypeMessages[type ?? 'unknown'] ?? fileTypeMessages.unknown)
|
||||
}
|
||||
|
||||
function additionalFileKey(file: Labrinth.Versions.v3.VersionFile) {
|
||||
return file.hashes?.sha1 ?? file.filename
|
||||
}
|
||||
|
||||
function dedupeDependencyRows(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenDependencies = new Set<string>(),
|
||||
): DownloadDependencyRow[] {
|
||||
return rows.flatMap((row) => {
|
||||
const identity = dependencyRowIdentity(row)
|
||||
if (seenDependencies.has(identity)) return []
|
||||
|
||||
seenDependencies.add(identity)
|
||||
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
dependencies: dedupeDependencyRows(row.dependencies, seenDependencies),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function dependencyRowIdentity(row: DownloadDependencyRow) {
|
||||
return row.projectHref ?? row.downloadHref ?? row.key
|
||||
}
|
||||
|
||||
function hasDuplicateDependencyRows(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenDependencies = new Set<string>(),
|
||||
): boolean {
|
||||
for (const row of rows) {
|
||||
const rowId = dependencyRowIdentity(row)
|
||||
if (seenDependencies.has(rowId)) return true
|
||||
seenDependencies.add(rowId)
|
||||
if (hasDuplicateDependencyRows(row.dependencies, seenDependencies)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function collectDownloadableDependencyFiles(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenHrefs = new Set<string>(),
|
||||
): DownloadableDependencyFile[] {
|
||||
const files: DownloadableDependencyFile[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.downloadHref && !seenHrefs.has(row.downloadHref)) {
|
||||
seenHrefs.add(row.downloadHref)
|
||||
files.push({
|
||||
href: row.downloadHref,
|
||||
filename: row.filename || filenameFromUrl(row.downloadHref),
|
||||
name: row.name,
|
||||
})
|
||||
}
|
||||
|
||||
files.push(...collectDownloadableDependencyFiles(row.dependencies, seenHrefs))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function filenameFromUrl(url: string) {
|
||||
try {
|
||||
const filename = new URL(url).pathname.split('/').pop()
|
||||
return filename ? decodeURIComponent(filename) : 'dependency.jar'
|
||||
} catch {
|
||||
return 'dependency.jar'
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
dependenciesTitle: {
|
||||
id: 'project.download.dependencies-title',
|
||||
defaultMessage: 'Dependencies',
|
||||
},
|
||||
duplicateDependenciesHidden: {
|
||||
id: 'project.download.duplicate-dependencies-hidden',
|
||||
defaultMessage: 'Duplicate dependencies are hidden',
|
||||
},
|
||||
additionalFilesTitle: {
|
||||
id: 'project.download.additional-files-title',
|
||||
defaultMessage: 'Additional files',
|
||||
},
|
||||
alreadyInstalledDependency: {
|
||||
id: 'project.download.dependency-already-installed',
|
||||
defaultMessage: 'This dependency is already installed',
|
||||
},
|
||||
conflictingDependency: {
|
||||
id: 'project.download.dependency-conflicting',
|
||||
defaultMessage: 'This dependency conflicts with another dependency',
|
||||
},
|
||||
duplicateDependency: {
|
||||
id: 'project.download.dependency-duplicate',
|
||||
defaultMessage: 'This dependency is already included',
|
||||
},
|
||||
missingDependencyVersion: {
|
||||
id: 'project.download.dependency-missing-version',
|
||||
defaultMessage: 'This dependency version is unavailable',
|
||||
},
|
||||
noCompatibleDependency: {
|
||||
id: 'project.download.dependency-no-compatible-version',
|
||||
defaultMessage: 'No compatible version is available for this dependency',
|
||||
},
|
||||
quiltFabricApiDependency: {
|
||||
id: 'project.download.dependency-quilt-fabric-api',
|
||||
defaultMessage: 'Fabric API is skipped for Quilt',
|
||||
},
|
||||
unavailableDependency: {
|
||||
id: 'project.download.dependency-unavailable',
|
||||
defaultMessage: 'This dependency cannot be downloaded',
|
||||
},
|
||||
unavailableFile: {
|
||||
id: 'project.download.file-unavailable',
|
||||
defaultMessage: 'This file cannot be downloaded',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div
|
||||
class="grid min-h-10 grid-cols-[minmax(0,1fr)_min-content] items-center gap-3 rounded-xl bg-button-bg py-0 pl-3.5 pr-2 text-primary"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-3">
|
||||
<Avatar
|
||||
v-if="dependency.icon"
|
||||
:src="dependency.icon"
|
||||
:alt="dependency.name"
|
||||
size="24px"
|
||||
class="!rounded-lg !shadow-none"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex size-4 flex-shrink-0 items-center justify-center rounded-lg border border-solid border-surface-5 text-secondary"
|
||||
>
|
||||
<component
|
||||
:is="dependency.fallbackIcon ?? PackageIcon"
|
||||
aria-hidden="true"
|
||||
class="size-5"
|
||||
/>
|
||||
</span>
|
||||
<a
|
||||
v-if="dependency.projectHref"
|
||||
ref="dependencyNameRef"
|
||||
v-tooltip="truncatedTooltip(dependencyNameRef, dependency.name)"
|
||||
:href="dependency.projectHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="min-w-0 truncate text-base font-semibold text-contrast no-underline hover:underline"
|
||||
>
|
||||
{{ dependency.name }}
|
||||
</a>
|
||||
<span
|
||||
v-else
|
||||
ref="dependencyNameRef"
|
||||
v-tooltip="truncatedTooltip(dependencyNameRef, dependency.name)"
|
||||
class="min-w-0 truncate text-base font-semibold text-contrast"
|
||||
>
|
||||
{{ dependency.name }}
|
||||
</span>
|
||||
<TagItem class="shrink-0 border !border-solid border-surface-5 !px-3 !py-1 text-base">
|
||||
{{ dependency.typeLabel }}
|
||||
</TagItem>
|
||||
</span>
|
||||
<ButtonStyled v-if="dependency.downloadHref" circular type="transparent">
|
||||
<a
|
||||
v-tooltip="downloadTooltip"
|
||||
:href="dependency.downloadHref"
|
||||
:download="dependency.filename"
|
||||
:aria-label="downloadTooltip"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" class="size-6 text-secondary" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else circular type="transparent">
|
||||
<button
|
||||
v-tooltip="dependency.unavailableTooltip"
|
||||
disabled
|
||||
:aria-label="dependency.unavailableTooltip"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" class="size-6 text-secondary" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
v-for="childDependency in dependency.dependencies"
|
||||
:key="childDependency.key"
|
||||
class="group/dependency relative pl-10"
|
||||
>
|
||||
<DownloadDependency :dependency="childDependency" class="z-1" @download="emit('download')" />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute -top-2 left-6 z-0 h-[calc(100%+1rem)] w-0.5 bg-surface-5 group-first/dependency:-top-2 group-first/dependency:h-20 group-last/dependency:h-7"
|
||||
/>
|
||||
<div aria-hidden="true" class="absolute left-6 top-5 z-0 h-0.5 w-4 bg-surface-5" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, PackageIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
TagItem,
|
||||
truncatedTooltip,
|
||||
useFormatBytes,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { type Component, computed, ref } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'DownloadDependency',
|
||||
})
|
||||
|
||||
interface DownloadDependencyRow {
|
||||
key: string
|
||||
name: string
|
||||
icon?: string
|
||||
fallbackIcon?: Component
|
||||
projectHref?: string
|
||||
downloadHref?: string
|
||||
filename?: string
|
||||
fileSize?: number
|
||||
typeLabel: string
|
||||
unavailableTooltip: string
|
||||
dependencies: DownloadDependencyRow[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
dependency: DownloadDependencyRow
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatBytes = useFormatBytes()
|
||||
const dependencyNameRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const downloadTooltip = computed(() => {
|
||||
const filename = props.dependency.filename || props.dependency.name
|
||||
|
||||
if (typeof props.dependency.fileSize === 'number') {
|
||||
return formatMessage(messages.downloadFileWithSize, {
|
||||
filename,
|
||||
size: formatBytes(props.dependency.fileSize, 1),
|
||||
})
|
||||
}
|
||||
|
||||
return formatMessage(messages.downloadFile, { filename })
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
downloadFile: {
|
||||
id: 'project.download.dependency-download-file',
|
||||
defaultMessage: 'Download {filename}',
|
||||
},
|
||||
downloadFileWithSize: {
|
||||
id: 'project.download.dependency-download-file-with-size',
|
||||
defaultMessage: 'Download {filename} ({size})',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,766 @@
|
||||
<template>
|
||||
<div class="flex w-full gap-2 max-sm:flex-wrap">
|
||||
<Combobox
|
||||
:model-value="currentGameVersion || undefined"
|
||||
class="w-full"
|
||||
:options="gameVersionOptions"
|
||||
:placeholder="formatMessage(messages.selectGameVersion)"
|
||||
:searchable="project.game_versions.length > 4"
|
||||
search-autocomplete="off"
|
||||
:search-placeholder="formatMessage(messages.searchGameVersions)"
|
||||
:no-options-message="formatMessage(messages.noGameVersionsFound)"
|
||||
trigger-class="!rounded-xl !bg-button-bg !px-3 !py-2"
|
||||
dropdown-class="!rounded-xl"
|
||||
select-search-text-on-focus
|
||||
@update:model-value="selectGameVersion"
|
||||
@search-input="versionFilter = $event"
|
||||
@close="versionFilter = ''"
|
||||
>
|
||||
<template #option="{ item, isSelected }">
|
||||
<div
|
||||
v-tooltip="gameVersionOptionTooltip(item.value)"
|
||||
class="flex w-full items-center justify-between gap-2 px-4 py-2"
|
||||
:class="{
|
||||
'text-brand-red opacity-40': isGameVersionUnavailable(item.value),
|
||||
'text-green': isSelected,
|
||||
'!opacity-100': isSelected,
|
||||
'text-primary': !isGameVersionUnavailable(item.value) && !isSelected,
|
||||
}"
|
||||
>
|
||||
<span class="min-w-0 truncate font-semibold leading-tight">{{ item.label }}</span>
|
||||
<TriangleAlertIcon
|
||||
v-if="isSelected && isGameVersionUnavailable(item.value)"
|
||||
aria-hidden="true"
|
||||
class="size-5 shrink-0 text-orange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #dropdown-footer>
|
||||
<div
|
||||
v-if="showVersionsCheckbox"
|
||||
class="border-0 border-t border-solid border-surface-5 p-3"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="showAllVersionsModel"
|
||||
:label="formatMessage(messages.showAllVersions)"
|
||||
:disabled="!!versionFilter"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Combobox>
|
||||
<Combobox
|
||||
v-if="project.project_type !== 'resourcepack'"
|
||||
:model-value="currentPlatform || undefined"
|
||||
class="w-full"
|
||||
:options="platformOptions"
|
||||
:placeholder="formatMessage(messages.selectPlatform)"
|
||||
trigger-class="!rounded-xl !bg-button-bg !px-3 !py-2"
|
||||
dropdown-class="!rounded-xl"
|
||||
@update:model-value="selectPlatform"
|
||||
>
|
||||
<template #option="{ item, isSelected }">
|
||||
<div
|
||||
v-tooltip="platformOptionTooltip(item.value, item.label)"
|
||||
class="flex w-full items-center justify-between gap-2 px-4 py-2"
|
||||
:class="{
|
||||
'text-brand-red opacity-40': isPlatformUnavailable(item.value),
|
||||
'text-green': isSelected,
|
||||
'!opacity-100': isSelected,
|
||||
'text-primary': !isPlatformUnavailable(item.value) && !isSelected,
|
||||
}"
|
||||
>
|
||||
<span class="min-w-0 truncate font-semibold leading-tight">{{ item.label }}</span>
|
||||
<TriangleAlertIcon
|
||||
v-if="isSelected && isPlatformUnavailable(item.value)"
|
||||
aria-hidden="true"
|
||||
class="size-5 shrink-0 text-orange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedVersion" class="flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="relative top-0.5 m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.compatibleVersionTitle) }}
|
||||
</h3>
|
||||
<ButtonStyled v-if="downloadAllFiles.length > 1" type="transparent">
|
||||
<button :disabled="downloadingSelectedVersion" @click="downloadSelectedVersionFiles">
|
||||
<SpinnerIcon v-if="downloadingSelectedVersion" aria-hidden="true" class="animate-spin" />
|
||||
<DownloadIcon v-else aria-hidden="true" />
|
||||
{{
|
||||
formatMessage(
|
||||
downloadingSelectedVersion
|
||||
? messages.downloadingSelectedVersion
|
||||
: messages.downloadAllSelectedVersion,
|
||||
{
|
||||
current: selectedVersionDownloadProgress.current,
|
||||
total: selectedVersionDownloadProgress.total,
|
||||
},
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_min-content] items-center gap-3 rounded-2xl bg-surface-2 px-3 py-3"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<nuxt-link
|
||||
v-tooltip="truncatedTooltip(versionNumberRef, selectedVersion.version_number)"
|
||||
:to="`/${project.project_type}/${project.slug || project.id}/version/${selectedVersion.id}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block min-w-0 text-contrast no-underline hover:underline"
|
||||
>
|
||||
<span ref="versionNumberRef" class="block truncate font-semibold">
|
||||
{{ selectedVersion.version_number }}
|
||||
</span>
|
||||
</nuxt-link>
|
||||
<VersionChannelTag
|
||||
:channel="selectedVersion.version_type"
|
||||
class="relative -top-px !py-0.5"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
ref="versionNameRef"
|
||||
v-tooltip="truncatedTooltip(versionNameRef, selectedVersion.name)"
|
||||
class="m-0 w-fit max-w-full truncate text-sm text-secondary"
|
||||
>
|
||||
{{ selectedVersion.name }}
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled v-if="selectedPrimaryFile" color="brand" circular>
|
||||
<a
|
||||
v-tooltip="'Download'"
|
||||
:href="selectedPrimaryFileDownloadUrl"
|
||||
:download="selectedPrimaryFile.filename"
|
||||
:aria-label="
|
||||
formatMessage(messages.downloadVersion, {
|
||||
version: selectedVersion.version_number,
|
||||
})
|
||||
"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="currentPlatform && currentGameVersion && versions.length > 0">
|
||||
{{
|
||||
formatMessage(messages.noVersionsAvailable, {
|
||||
gameVersion: currentGameVersion,
|
||||
platform: currentPlatformText,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { DownloadIcon, SpinnerIcon, TriangleAlertIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
type CdnDownloadReason,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
defineMessages,
|
||||
getTagMessage,
|
||||
injectNotificationManager,
|
||||
truncatedTooltip,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import VersionChannelTag from '@modrinth/ui/src/components/version/VersionChannelTag.vue'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import JSZip from 'jszip'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'DownloadProject',
|
||||
})
|
||||
|
||||
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
|
||||
project_type: DisplayProjectType
|
||||
actualProjectType: Labrinth.Projects.v2.ProjectType
|
||||
}
|
||||
|
||||
type ProjectDownloadSelection = {
|
||||
currentGameVersion: string | null
|
||||
currentPlatform: string | null
|
||||
selectedVersion: Labrinth.Versions.v3.Version | null
|
||||
selectedPrimaryFile: Labrinth.Versions.v3.VersionFile | null
|
||||
}
|
||||
|
||||
type DownloadableFile = {
|
||||
href: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
project: DownloadModalProject
|
||||
versions?: Labrinth.Versions.v3.Version[]
|
||||
dependencyDownloadFiles?: DownloadableFile[]
|
||||
downloadReason?: CdnDownloadReason
|
||||
initialGameVersion?: string | null
|
||||
initialPlatform?: string | null
|
||||
incompatibleGameVersions?: string[]
|
||||
incompatibleLoaders?: string[]
|
||||
resetKey?: number
|
||||
}>(),
|
||||
{
|
||||
versions: () => [],
|
||||
dependencyDownloadFiles: () => [],
|
||||
downloadReason: 'standalone',
|
||||
initialGameVersion: null,
|
||||
initialPlatform: null,
|
||||
incompatibleGameVersions: () => [],
|
||||
incompatibleLoaders: () => [],
|
||||
resetKey: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: []
|
||||
selectGameVersion: [gameVersion: string]
|
||||
selectPlatform: [platform: string]
|
||||
'update:selection': [selection: ProjectDownloadSelection]
|
||||
}>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const debug = useDebugLogger('DownloadProject')
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const userSelectedGameVersion = ref<string | null>(props.initialGameVersion)
|
||||
const userSelectedPlatform = ref<string | null>(props.initialPlatform)
|
||||
const showAllVersions = ref(defaultShowAllVersions())
|
||||
const versionFilter = ref('')
|
||||
const versionNumberRef = ref<HTMLElement | null>(null)
|
||||
const versionNameRef = ref<HTMLElement | null>(null)
|
||||
const downloadingSelectedVersion = ref(false)
|
||||
const selectedVersionDownloadProgress = ref({
|
||||
current: 0,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const incompatibleGameVersionsSet = computed(() => new Set(props.incompatibleGameVersions))
|
||||
const incompatibleLoadersSet = computed(() => new Set(props.incompatibleLoaders))
|
||||
|
||||
const showAllVersionsModel = computed({
|
||||
get() {
|
||||
return showAllVersions.value
|
||||
},
|
||||
set(value) {
|
||||
showAllVersions.value = value
|
||||
},
|
||||
})
|
||||
|
||||
const selectedPlatform = computed<string | null>(() => {
|
||||
if (userSelectedPlatform.value) return userSelectedPlatform.value
|
||||
return props.project.loaders.length === 1 ? props.project.loaders[0] : null
|
||||
})
|
||||
|
||||
const selectedGameVersion = computed<string | null>(() => {
|
||||
if (userSelectedGameVersion.value) return userSelectedGameVersion.value
|
||||
return props.project.game_versions.length === 1 ? props.project.game_versions[0] : null
|
||||
})
|
||||
|
||||
const compatiblePlatforms = computed<string[]>(() => {
|
||||
return props.project.loaders.filter(
|
||||
(platform) =>
|
||||
props.versions.some(
|
||||
(version) =>
|
||||
version.loaders.includes(platform) &&
|
||||
(!selectedGameVersion.value || version.game_versions.includes(selectedGameVersion.value)),
|
||||
) && !incompatibleLoadersSet.value.has(platform),
|
||||
)
|
||||
})
|
||||
|
||||
const currentPlatform = computed<string | null>(() => {
|
||||
if (selectedPlatform.value) return selectedPlatform.value
|
||||
return compatiblePlatforms.value.length === 1 ? compatiblePlatforms.value[0] : null
|
||||
})
|
||||
|
||||
const possibleGameVersions = computed<string[]>(() => {
|
||||
return props.versions
|
||||
.filter((x) => !currentPlatform.value || x.loaders.includes(currentPlatform.value))
|
||||
.flatMap((x) => x.game_versions)
|
||||
})
|
||||
|
||||
const compatibleGameVersions = computed<string[]>(() => {
|
||||
return props.project.game_versions.filter(
|
||||
(gameVersion) =>
|
||||
possibleGameVersions.value.includes(gameVersion) &&
|
||||
!incompatibleGameVersionsSet.value.has(gameVersion),
|
||||
)
|
||||
})
|
||||
|
||||
const currentGameVersion = computed<string | null>(() => {
|
||||
if (selectedGameVersion.value) return selectedGameVersion.value
|
||||
return compatibleGameVersions.value.length === 1 ? compatibleGameVersions.value[0] : null
|
||||
})
|
||||
|
||||
const possiblePlatforms = computed<string[]>(() => {
|
||||
return props.versions
|
||||
.filter((x) => !currentGameVersion.value || x.game_versions.includes(currentGameVersion.value))
|
||||
.flatMap((x) => x.loaders)
|
||||
})
|
||||
|
||||
const currentPlatformText = computed(() => {
|
||||
if (!currentPlatform.value) return ''
|
||||
return loaderLabel(currentPlatform.value)
|
||||
})
|
||||
|
||||
const releaseVersions = computed<Set<string>>(() => {
|
||||
const set = new Set<string>()
|
||||
for (const gameVersion of tags.value.gameVersions || []) {
|
||||
if (gameVersion?.version && gameVersion.version_type === 'release') {
|
||||
set.add(gameVersion.version)
|
||||
}
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
const nonReleaseVersions = computed<Set<string>>(() => {
|
||||
const set = new Set<string>()
|
||||
for (const gameVersion of tags.value.gameVersions || []) {
|
||||
if (gameVersion?.version && gameVersion.version_type !== 'release') {
|
||||
set.add(gameVersion.version)
|
||||
}
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
const showVersionsCheckbox = computed(() => {
|
||||
let hasRelease = false
|
||||
let hasNonRelease = false
|
||||
|
||||
for (const version of props.project.game_versions) {
|
||||
if (isReleaseGameVersion(version)) {
|
||||
hasRelease = true
|
||||
} else {
|
||||
hasNonRelease = true
|
||||
}
|
||||
|
||||
if (hasRelease && hasNonRelease) return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const filteredGameVersions = computed(() => {
|
||||
return props.project.game_versions
|
||||
.filter(
|
||||
(x) =>
|
||||
(versionFilter.value && x.includes(versionFilter.value)) ||
|
||||
(!versionFilter.value && (showAllVersions.value || isReleaseGameVersion(x))),
|
||||
)
|
||||
.slice()
|
||||
.reverse()
|
||||
})
|
||||
|
||||
const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
|
||||
return filteredGameVersions.value.map((gameVersion) => ({
|
||||
value: gameVersion,
|
||||
label: gameVersion,
|
||||
class: '!px-0 !py-1',
|
||||
}))
|
||||
})
|
||||
|
||||
const platformOptions = computed<ComboboxOption<string>[]>(() => {
|
||||
return props.project.loaders
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((platform) => ({
|
||||
value: platform,
|
||||
label: loaderLabel(platform),
|
||||
class: '!px-0 !py-1',
|
||||
}))
|
||||
})
|
||||
|
||||
const filteredVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
|
||||
const gameVersion = currentGameVersion.value
|
||||
if (!gameVersion) return []
|
||||
|
||||
const platform = currentPlatform.value
|
||||
const result = props.versions.filter((x) => {
|
||||
const matchesPlatform =
|
||||
props.project.project_type === 'resourcepack' || (!!platform && x.loaders.includes(platform))
|
||||
|
||||
return x.game_versions.includes(gameVersion) && matchesPlatform
|
||||
})
|
||||
debug('filteredVersions', {
|
||||
total: props.versions.length,
|
||||
filtered: result.length,
|
||||
currentGameVersion: currentGameVersion.value,
|
||||
currentPlatform: currentPlatform.value,
|
||||
sampleLoaders: props.versions.slice(0, 3).map((v) => v.loaders),
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const filteredRelease = computed<Labrinth.Versions.v3.Version | undefined>(() => {
|
||||
return filteredVersions.value.find((x) => x.version_type === 'release')
|
||||
})
|
||||
|
||||
const filteredBeta = computed<Labrinth.Versions.v3.Version | undefined>(() => {
|
||||
return filteredVersions.value.find(
|
||||
(x) =>
|
||||
x.version_type === 'beta' &&
|
||||
(!filteredRelease.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))),
|
||||
)
|
||||
})
|
||||
|
||||
const filteredAlpha = computed<Labrinth.Versions.v3.Version | undefined>(() => {
|
||||
return filteredVersions.value.find(
|
||||
(x) =>
|
||||
x.version_type === 'alpha' &&
|
||||
(!filteredRelease.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))) &&
|
||||
(!filteredBeta.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredBeta.value.date_published))),
|
||||
)
|
||||
})
|
||||
|
||||
const selectedVersion = computed<Labrinth.Versions.v3.Version | null>(() => {
|
||||
return filteredRelease.value || filteredBeta.value || filteredAlpha.value || null
|
||||
})
|
||||
|
||||
const selectedPrimaryFile = computed<Labrinth.Versions.v3.VersionFile | null>(() => {
|
||||
return (
|
||||
selectedVersion.value?.files?.find((file) => file.primary) ||
|
||||
selectedVersion.value?.files?.[0] ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const selectedPrimaryFileDownloadUrl = computed(() => {
|
||||
if (!selectedPrimaryFile.value) return '#'
|
||||
return getDownloadUrl(selectedPrimaryFile.value.url)
|
||||
})
|
||||
|
||||
const selectedVersionDownloadFiles = computed(() => {
|
||||
if (!selectedVersion.value) return []
|
||||
|
||||
return selectedVersion.value.files.map((file) => ({
|
||||
href: getDownloadUrl(file.url),
|
||||
filename: file.filename,
|
||||
}))
|
||||
})
|
||||
|
||||
const downloadAllFiles = computed(() => {
|
||||
const files: DownloadableFile[] = []
|
||||
const hrefs = new Set<string>()
|
||||
|
||||
for (const file of [...selectedVersionDownloadFiles.value, ...props.dependencyDownloadFiles]) {
|
||||
if (hrefs.has(file.href)) continue
|
||||
hrefs.add(file.href)
|
||||
files.push(file)
|
||||
}
|
||||
|
||||
return files
|
||||
})
|
||||
|
||||
const selectedVersionZipFilename = computed(() => {
|
||||
if (!selectedVersion.value) return `${sanitizeFilename(props.project.title)}.zip`
|
||||
|
||||
return `${sanitizeFilename(props.project.title)} ${sanitizeFilename(
|
||||
selectedVersion.value.version_number,
|
||||
)}.zip`
|
||||
})
|
||||
|
||||
watch(
|
||||
[currentGameVersion, currentPlatform, selectedVersion, selectedPrimaryFile],
|
||||
() => {
|
||||
emit('update:selection', {
|
||||
currentGameVersion: currentGameVersion.value,
|
||||
currentPlatform: currentPlatform.value,
|
||||
selectedVersion: selectedVersion.value,
|
||||
selectedPrimaryFile: selectedPrimaryFile.value,
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.resetKey,
|
||||
() => {
|
||||
userSelectedGameVersion.value = props.initialGameVersion
|
||||
userSelectedPlatform.value = props.initialPlatform
|
||||
showAllVersions.value = defaultShowAllVersions()
|
||||
versionFilter.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
function selectGameVersion(gameVersion?: string) {
|
||||
if (!gameVersion) return
|
||||
userSelectedGameVersion.value = gameVersion
|
||||
emit('selectGameVersion', gameVersion)
|
||||
selectOnlyCompatiblePlatform()
|
||||
}
|
||||
|
||||
function selectPlatform(platform?: string) {
|
||||
if (!platform) return
|
||||
userSelectedPlatform.value = platform
|
||||
emit('selectPlatform', platform)
|
||||
}
|
||||
|
||||
function getDownloadUrl(url: string) {
|
||||
return createProjectDownloadUrl(url, {
|
||||
reason: props.downloadReason,
|
||||
gameVersion: currentGameVersion.value ?? undefined,
|
||||
loader: currentPlatform.value ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async function downloadSelectedVersionFiles() {
|
||||
if (downloadingSelectedVersion.value || downloadAllFiles.value.length <= 1) return
|
||||
|
||||
downloadingSelectedVersion.value = true
|
||||
const files = [...downloadAllFiles.value]
|
||||
selectedVersionDownloadProgress.value = {
|
||||
current: 0,
|
||||
total: files.length,
|
||||
}
|
||||
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
const usedFilenames = new Set<string>()
|
||||
|
||||
for (const [index, file] of files.entries()) {
|
||||
selectedVersionDownloadProgress.value = {
|
||||
current: index + 1,
|
||||
total: files.length,
|
||||
}
|
||||
const response = await fetch(file.href)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${file.filename}`)
|
||||
}
|
||||
|
||||
zip.file(uniqueFilename(file.filename, usedFilenames), await response.blob())
|
||||
}
|
||||
|
||||
downloadBlob(
|
||||
await zip.generateAsync({
|
||||
type: 'blob',
|
||||
mimeType: 'application/zip',
|
||||
}),
|
||||
selectedVersionZipFilename.value,
|
||||
)
|
||||
emit('download')
|
||||
} catch (error) {
|
||||
console.error('Failed to download selected version files:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.downloadSelectedVersionFailedTitle),
|
||||
text: formatMessage(messages.downloadSelectedVersionFailedText),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
downloadingSelectedVersion.value = false
|
||||
selectedVersionDownloadProgress.value = {
|
||||
current: 0,
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
|
||||
function sanitizeFilename(value: string) {
|
||||
const sanitized = value
|
||||
.replace(/[<>:"/\\|?*]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
return sanitized || 'download'
|
||||
}
|
||||
|
||||
function uniqueFilename(filename: string, usedFilenames: Set<string>) {
|
||||
const sanitizedFilename = sanitizeFilename(filename)
|
||||
|
||||
if (!usedFilenames.has(sanitizedFilename)) {
|
||||
usedFilenames.add(sanitizedFilename)
|
||||
return sanitizedFilename
|
||||
}
|
||||
|
||||
const extensionIndex = sanitizedFilename.lastIndexOf('.')
|
||||
const basename =
|
||||
extensionIndex > 0 ? sanitizedFilename.slice(0, extensionIndex) : sanitizedFilename
|
||||
const extension = extensionIndex > 0 ? sanitizedFilename.slice(extensionIndex) : ''
|
||||
let index = 2
|
||||
let candidate = `${basename} (${index})${extension}`
|
||||
|
||||
while (usedFilenames.has(candidate)) {
|
||||
index += 1
|
||||
candidate = `${basename} (${index})${extension}`
|
||||
}
|
||||
|
||||
usedFilenames.add(candidate)
|
||||
return candidate
|
||||
}
|
||||
|
||||
function loaderLabel(loader: string) {
|
||||
return formatMessage(getTagMessage(loader, 'loader') ?? messages.unknownLoader)
|
||||
}
|
||||
|
||||
function isReleaseGameVersion(version: string) {
|
||||
if (releaseVersions.value.has(version)) return true
|
||||
if (nonReleaseVersions.value.has(version)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function defaultShowAllVersions() {
|
||||
return (
|
||||
props.project.game_versions.length > 0 &&
|
||||
props.project.game_versions.every((projectVersion) => {
|
||||
const gameVersion = tags.value.gameVersions?.find((x) => x.version === projectVersion)
|
||||
return !!gameVersion?.version_type && gameVersion.version_type !== 'release'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function isGameVersionUnavailable(gameVersion: string) {
|
||||
return (
|
||||
incompatibleGameVersionsSet.value.has(gameVersion) ||
|
||||
!possibleGameVersions.value.includes(gameVersion)
|
||||
)
|
||||
}
|
||||
|
||||
function isPlatformUnavailable(platform: string) {
|
||||
return incompatibleLoadersSet.value.has(platform) || !possiblePlatforms.value.includes(platform)
|
||||
}
|
||||
|
||||
function selectOnlyCompatiblePlatform() {
|
||||
const compatiblePlatforms = props.project.loaders.filter(
|
||||
(platform) =>
|
||||
possiblePlatforms.value.includes(platform) && !incompatibleLoadersSet.value.has(platform),
|
||||
)
|
||||
|
||||
if (compatiblePlatforms.length !== 1) return
|
||||
|
||||
userSelectedPlatform.value = compatiblePlatforms[0]
|
||||
emit('selectPlatform', compatiblePlatforms[0])
|
||||
}
|
||||
|
||||
function gameVersionOptionTooltip(gameVersion: string) {
|
||||
if (incompatibleGameVersionsSet.value.has(gameVersion)) {
|
||||
return formatMessage(messages.baseGameVersionIncompatibleTooltip)
|
||||
}
|
||||
|
||||
if (!possibleGameVersions.value.includes(gameVersion)) {
|
||||
return formatMessage(messages.gameVersionUnsupportedTooltip, {
|
||||
title: props.project.title,
|
||||
gameVersion,
|
||||
platform: currentPlatformText.value,
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function platformOptionTooltip(platform: string, platformLabel: string) {
|
||||
if (incompatibleLoadersSet.value.has(platform)) {
|
||||
return formatMessage(messages.baseLoaderIncompatibleTooltip)
|
||||
}
|
||||
|
||||
if (!possiblePlatforms.value.includes(platform)) {
|
||||
return formatMessage(messages.platformUnsupportedTooltip, {
|
||||
title: props.project.title,
|
||||
platform: platformLabel,
|
||||
gameVersion: currentGameVersion.value,
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
baseGameVersionIncompatibleTooltip: {
|
||||
id: 'project.download.base-game-version-incompatible-tooltip',
|
||||
defaultMessage: 'This game version is incompatible with the base project.',
|
||||
},
|
||||
baseLoaderIncompatibleTooltip: {
|
||||
id: 'project.download.base-loader-incompatible-tooltip',
|
||||
defaultMessage: 'This loader is incompatible with the base project.',
|
||||
},
|
||||
gameVersionUnsupportedTooltip: {
|
||||
id: 'project.download.game-version-unsupported-tooltip',
|
||||
defaultMessage: '{title} does not support {gameVersion} for {platform}',
|
||||
},
|
||||
downloadVersion: {
|
||||
id: 'project.download.download-version',
|
||||
defaultMessage: 'Download {version}',
|
||||
},
|
||||
compatibleVersionTitle: {
|
||||
id: 'project.download.compatible-version-title',
|
||||
defaultMessage: 'Compatible version',
|
||||
},
|
||||
downloadAllSelectedVersion: {
|
||||
id: 'project.download.selected-version-download-all',
|
||||
defaultMessage: 'Download all (.zip)',
|
||||
},
|
||||
downloadingSelectedVersion: {
|
||||
id: 'project.download.selected-version-downloading',
|
||||
defaultMessage: 'Downloading... ({current}/{total})',
|
||||
},
|
||||
downloadSelectedVersionFailedTitle: {
|
||||
id: 'project.download.selected-version-failed-title',
|
||||
defaultMessage: 'Could not download version',
|
||||
},
|
||||
downloadSelectedVersionFailedText: {
|
||||
id: 'project.download.selected-version-failed-text',
|
||||
defaultMessage: 'One or more version files could not be downloaded. Please try again.',
|
||||
},
|
||||
noGameVersionsFound: {
|
||||
id: 'project.download.no-game-versions-found',
|
||||
defaultMessage: 'No game versions found',
|
||||
},
|
||||
noVersionsAvailable: {
|
||||
id: 'project.download.no-versions-available',
|
||||
defaultMessage: 'No versions available for {gameVersion} and {platform}.',
|
||||
},
|
||||
platformUnsupportedTooltip: {
|
||||
id: 'project.download.platform-unsupported-tooltip',
|
||||
defaultMessage: '{title} does not support {platform} for {gameVersion}',
|
||||
},
|
||||
searchGameVersions: {
|
||||
id: 'project.download.search-game-versions',
|
||||
defaultMessage: 'Select game version',
|
||||
},
|
||||
selectGameVersion: {
|
||||
id: 'project.download.select-game-version',
|
||||
defaultMessage: 'Select game version',
|
||||
},
|
||||
selectPlatform: {
|
||||
id: 'project.download.select-platform',
|
||||
defaultMessage: 'Select platform',
|
||||
},
|
||||
showAllVersions: {
|
||||
id: 'project.download.show-all-versions',
|
||||
defaultMessage: 'Show all versions',
|
||||
},
|
||||
unknownLoader: {
|
||||
id: 'project.download.unknown-loader',
|
||||
defaultMessage: 'Unknown loader',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="
|
||||
project.project_type !== 'plugin' ||
|
||||
project.loaders.some((x) => !tags.loaderData.allPluginLoaders.includes(x))
|
||||
"
|
||||
class="modrinth-app-section contents"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<a
|
||||
class="modrinth-app-install-card flex items-center justify-between gap-3 rounded-2xl border border-solid border-brand-highlight bg-surface-1 px-4 py-3 text-primary no-underline transition-[filter] hover:brightness-110"
|
||||
:href="`modrinth://mod/${project.slug}`"
|
||||
@click="installWithApp"
|
||||
>
|
||||
<span class="flex w-full min-w-0 flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex min-w-0 items-center gap-1.5 font-medium text-contrast">
|
||||
Install with
|
||||
<span class="text-brand">Modrinth App</span>
|
||||
<ModrinthIcon aria-hidden="true" class="size-4 flex-shrink-0 text-brand" />
|
||||
</span>
|
||||
<ExternalIcon
|
||||
aria-hidden="true"
|
||||
class="size-4 flex-shrink-0 text-contrast transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<span class="truncate text-base text-contrast opacity-80">
|
||||
{{ formatMessage(messages.installWithModrinthAppDescription) }}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<Accordion ref="getModrinthAppAccordion">
|
||||
<nuxt-link class="mt-2 flex justify-center text-brand-blue hover:underline" to="/app">
|
||||
{{ formatMessage(messages.dontHaveModrinthApp) }}
|
||||
</nuxt-link>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
||||
<span class="flex-shrink-0 text-sm font-medium text-secondary">
|
||||
{{ formatMessage(messages.downloadManually) }}
|
||||
</span>
|
||||
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ExternalIcon, ModrinthIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Accordion from '~/components/ui/Accordion.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'InstallWithModrinthApp',
|
||||
})
|
||||
|
||||
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
|
||||
project_type: DisplayProjectType
|
||||
actualProjectType: Labrinth.Projects.v2.ProjectType
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
project: DownloadModalProject
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const tags = useGeneratedState()
|
||||
const getModrinthAppAccordion = ref<InstanceType<typeof Accordion> | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
dontHaveModrinthApp: {
|
||||
id: 'project.download.no-app',
|
||||
defaultMessage: "Don't have Modrinth App?",
|
||||
},
|
||||
downloadManually: {
|
||||
id: 'project.download.manually',
|
||||
defaultMessage: 'Download manually',
|
||||
},
|
||||
installWithModrinthAppDescription: {
|
||||
id: 'project.download.install-with-app-description',
|
||||
defaultMessage: 'Automatically install the correct version and dependencies.',
|
||||
},
|
||||
})
|
||||
|
||||
function installWithApp() {
|
||||
setTimeout(() => {
|
||||
getModrinthAppAccordion.value?.open()
|
||||
}, 1500)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modrinth-app-install-card {
|
||||
background: radial-gradient(
|
||||
ellipse 90% 250% at 50% 200%,
|
||||
color-mix(in srgb, var(--color-brand-shadow) 50%, var(--surface-1)) -30%,
|
||||
var(--surface-1) 72%
|
||||
);
|
||||
}
|
||||
|
||||
@media (hover: none) and (max-width: 767px) {
|
||||
.modrinth-app-section {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,447 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :on-show="onShow" :on-hide="onHide" width="544px">
|
||||
<template #title>
|
||||
<template v-if="project">
|
||||
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
|
||||
<div
|
||||
ref="downloadTitleRef"
|
||||
v-tooltip="truncatedTooltip(downloadTitleRef, downloadTitle)"
|
||||
class="truncate text-lg font-extrabold text-contrast"
|
||||
>
|
||||
{{ downloadTitle }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="project" class="mx-auto flex w-full flex-col gap-4">
|
||||
<InstallWithModrinthApp :project="project" />
|
||||
<DownloadProject
|
||||
:project="project"
|
||||
:versions="versions"
|
||||
:dependency-download-files="dependencyDownloadFiles"
|
||||
:download-reason="downloadReason"
|
||||
:initial-game-version="initialGameVersion"
|
||||
:initial-platform="initialPlatform"
|
||||
:incompatible-game-versions="showOptions.incompatibleGameVersions"
|
||||
:incompatible-loaders="showOptions.incompatibleLoaders"
|
||||
:reset-key="downloadProjectResetKey"
|
||||
@select-game-version="selectGameVersion"
|
||||
@select-platform="selectPlatform"
|
||||
@update:selection="projectDownloadSelection = $event"
|
||||
@download="onDownload"
|
||||
/>
|
||||
<div class="flex flex-col gap-4">
|
||||
<DownloadDependencies
|
||||
:project="project"
|
||||
:selected-version="selectedVersion"
|
||||
:current-game-version="currentGameVersion"
|
||||
:current-platform="currentPlatform"
|
||||
:download-reason="downloadReason"
|
||||
:additional-files="additionalFiles"
|
||||
@update:downloadable-files="dependencyDownloadFiles = $event"
|
||||
@download="onDownload"
|
||||
/>
|
||||
</div>
|
||||
<ServersPromo
|
||||
v-if="flags.showProjectPageDownloadModalServersPromo"
|
||||
:link="`/hosting#plan`"
|
||||
@close="
|
||||
() => {
|
||||
flags.showProjectPageDownloadModalServersPromo = false
|
||||
saveFeatureFlags()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
Avatar,
|
||||
type CdnDownloadReason,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
NewModal,
|
||||
ServersPromo,
|
||||
truncatedTooltip,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { navigateTo } from '#app'
|
||||
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
|
||||
|
||||
import DownloadDependencies from './DownloadDependencies.vue'
|
||||
import DownloadProject from './DownloadProject.vue'
|
||||
import InstallWithModrinthApp from './InstallWithModrinthApp.vue'
|
||||
|
||||
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
|
||||
project_type: DisplayProjectType
|
||||
actualProjectType: Labrinth.Projects.v2.ProjectType
|
||||
}
|
||||
|
||||
type ProjectDownloadSelection = {
|
||||
currentGameVersion: string | null
|
||||
currentPlatform: string | null
|
||||
selectedVersion: Labrinth.Versions.v3.Version | null
|
||||
selectedPrimaryFile: Labrinth.Versions.v3.VersionFile | null
|
||||
}
|
||||
|
||||
type DownloadableFile = {
|
||||
href: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
type NewModalRef = {
|
||||
show: (event?: MouseEvent) => void
|
||||
hide: () => void
|
||||
}
|
||||
|
||||
type ProjectDownloadModalShowOptions = {
|
||||
projectId?: string
|
||||
incompatibleGameVersions?: string[]
|
||||
incompatibleLoaders?: string[]
|
||||
}
|
||||
|
||||
type ResolvedProjectDownloadModalShowOptions = {
|
||||
projectId?: string
|
||||
incompatibleGameVersions: string[]
|
||||
incompatibleLoaders: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
projectId?: string
|
||||
downloadReason?: CdnDownloadReason
|
||||
useRouteHash?: boolean
|
||||
updateRouteSelection?: boolean
|
||||
}>(),
|
||||
{
|
||||
downloadReason: 'standalone',
|
||||
useRouteHash: true,
|
||||
updateRouteSelection: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: []
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const flags = useFeatureFlags()
|
||||
const tags = useGeneratedState()
|
||||
const client = injectModrinthClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
const debug = useDebugLogger('DownloadModal')
|
||||
|
||||
const modal = ref<NewModalRef | null>(null)
|
||||
const downloadTitleRef = ref<HTMLElement | null>(null)
|
||||
const modalOpen = ref(false)
|
||||
const showProjectId = ref<string | null>(null)
|
||||
const showOptions = ref<ResolvedProjectDownloadModalShowOptions>(getDefaultShowOptions())
|
||||
const downloadProjectResetKey = ref(0)
|
||||
const projectDownloadSelection = ref<ProjectDownloadSelection>(getDefaultProjectDownloadSelection())
|
||||
const dependencyDownloadFiles = ref<DownloadableFile[]>([])
|
||||
const MODAL_CLOSE_STATE_RESET_MS = 350
|
||||
let closeStateResetTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const routeProjectId = computed(() => showProjectId.value ?? props.projectId ?? null)
|
||||
|
||||
const {
|
||||
data: projectRaw,
|
||||
error: projectV2Error,
|
||||
refetch: refetchProject,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => ['project', 'v2', routeProjectId.value]),
|
||||
queryFn: () => client.labrinth.projects_v2.get(routeProjectId.value!),
|
||||
enabled: computed(() => !!routeProjectId.value),
|
||||
staleTime: STALE_TIME,
|
||||
})
|
||||
|
||||
const resolvedProjectId = computed(() => projectRaw.value?.id)
|
||||
|
||||
const project = computed<DownloadModalProject | null>(() => {
|
||||
if (!projectRaw.value) return null
|
||||
|
||||
return {
|
||||
...projectRaw.value,
|
||||
actualProjectType: projectRaw.value.project_type,
|
||||
project_type: getProjectTypeForUrl(projectRaw.value.project_type, projectRaw.value.loaders),
|
||||
}
|
||||
})
|
||||
|
||||
const downloadTitle = computed(() => {
|
||||
if (!project.value) return ''
|
||||
return formatMessage(messages.downloadTitle, { title: project.value.title })
|
||||
})
|
||||
|
||||
const versionsEnabled = ref(false)
|
||||
const {
|
||||
data: versionsV3,
|
||||
error: _versionsV3Error,
|
||||
isFetching: versionsV3Loading,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => ['project', resolvedProjectId.value, 'versions', 'v3']),
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v3.getProjectVersions(resolvedProjectId.value!, {
|
||||
include_changelog: false,
|
||||
apiVersion: 3,
|
||||
}),
|
||||
staleTime: STALE_TIME_LONG,
|
||||
enabled: computed(() => !!resolvedProjectId.value && versionsEnabled.value),
|
||||
})
|
||||
|
||||
const versions = computed<Labrinth.Versions.v3.Version[]>(() => {
|
||||
const isModpack =
|
||||
project.value?.actualProjectType === 'modpack' || project.value?.project_type === 'modpack'
|
||||
|
||||
return (versionsV3.value ?? []).map((version) => {
|
||||
const files = Array.isArray(version.files) ? version.files : []
|
||||
const gameVersions = Array.isArray(version.game_versions) ? version.game_versions : []
|
||||
const loaders = Array.isArray(version.loaders) ? version.loaders : []
|
||||
const mrpackLoaders = Array.isArray(version.mrpack_loaders) ? version.mrpack_loaders : []
|
||||
|
||||
return {
|
||||
...version,
|
||||
files,
|
||||
game_versions: gameVersions,
|
||||
loaders: isModpack && mrpackLoaders.length ? mrpackLoaders : loaders,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const initialGameVersion = computed(() => {
|
||||
const version = route.query.version
|
||||
if (typeof version !== 'string' || !project.value?.game_versions.includes(version)) return null
|
||||
return version
|
||||
})
|
||||
const initialPlatform = computed(() => {
|
||||
const loader = route.query.loader
|
||||
if (typeof loader !== 'string' || !project.value?.loaders.includes(loader)) return null
|
||||
return loader
|
||||
})
|
||||
|
||||
const currentGameVersion = computed(() => projectDownloadSelection.value.currentGameVersion)
|
||||
const currentPlatform = computed(() => projectDownloadSelection.value.currentPlatform)
|
||||
const selectedVersion = computed(() => projectDownloadSelection.value.selectedVersion)
|
||||
const selectedPrimaryFile = computed(() => projectDownloadSelection.value.selectedPrimaryFile)
|
||||
|
||||
const additionalFiles = computed(() => {
|
||||
if (!selectedVersion.value || !selectedPrimaryFile.value) return []
|
||||
return selectedVersion.value.files.filter((file) => file !== selectedPrimaryFile.value)
|
||||
})
|
||||
|
||||
watch(projectV2Error, (error) => {
|
||||
if (error) {
|
||||
debug('project query failed', error)
|
||||
}
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
downloadTitle: {
|
||||
id: 'project.download.title',
|
||||
defaultMessage: 'Download {title}',
|
||||
},
|
||||
})
|
||||
|
||||
function getProjectTypeForUrl(
|
||||
type: Labrinth.Projects.v2.ProjectType,
|
||||
loaders: string[],
|
||||
): DisplayProjectType {
|
||||
if (type !== 'mod') return type as DisplayProjectType
|
||||
|
||||
const isMod = loaders.some((loader) => tags.value.loaderData.modLoaders.includes(loader))
|
||||
const isPlugin = loaders.some((loader) => tags.value.loaderData.allPluginLoaders.includes(loader))
|
||||
const isDataPack = loaders.some((loader) =>
|
||||
tags.value.loaderData.dataPackLoaders.includes(loader),
|
||||
)
|
||||
|
||||
if (isDataPack) return 'datapack'
|
||||
if (isPlugin) return 'plugin'
|
||||
if (isMod) return 'mod'
|
||||
|
||||
return 'mod'
|
||||
}
|
||||
|
||||
function updateDownloadQuery({
|
||||
gameVersion,
|
||||
platform,
|
||||
}: {
|
||||
gameVersion: string | null
|
||||
platform: string | null
|
||||
}) {
|
||||
if (!props.updateRouteSelection) return
|
||||
navigateTo(
|
||||
{
|
||||
query: {
|
||||
...route.query,
|
||||
...(gameVersion && {
|
||||
version: gameVersion,
|
||||
}),
|
||||
...(platform && {
|
||||
loader: platform,
|
||||
}),
|
||||
},
|
||||
hash: route.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
|
||||
function selectGameVersion(gameVersion: string) {
|
||||
updateDownloadQuery({
|
||||
gameVersion,
|
||||
platform: currentPlatform.value,
|
||||
})
|
||||
}
|
||||
|
||||
function selectPlatform(platform: string) {
|
||||
updateDownloadQuery({
|
||||
gameVersion: currentGameVersion.value,
|
||||
platform,
|
||||
})
|
||||
}
|
||||
|
||||
function onShow() {
|
||||
clearCloseStateResetTimeout()
|
||||
modalOpen.value = true
|
||||
debug('on-show fired')
|
||||
versionsEnabled.value = true
|
||||
if (props.useRouteHash && !showProjectId.value) {
|
||||
navigateTo({ query: route.query, hash: '#download' }, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
function onHide() {
|
||||
const hadShowProjectId = !!showProjectId.value
|
||||
modalOpen.value = false
|
||||
clearCloseStateResetTimeout()
|
||||
closeStateResetTimeout = setTimeout(() => {
|
||||
showProjectId.value = null
|
||||
showOptions.value = getDefaultShowOptions()
|
||||
closeStateResetTimeout = null
|
||||
}, MODAL_CLOSE_STATE_RESET_MS)
|
||||
if (props.useRouteHash && !hadShowProjectId) {
|
||||
navigateTo({ query: route.query, hash: '' }, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function show(
|
||||
event?: MouseEvent,
|
||||
options: ProjectDownloadModalShowOptions = {},
|
||||
): Promise<void> {
|
||||
if (!modal.value || modalOpen.value) return
|
||||
await waitForCloseStateReset()
|
||||
if (!modal.value || modalOpen.value) return
|
||||
showOptions.value = {
|
||||
...getDefaultShowOptions(),
|
||||
...options,
|
||||
}
|
||||
showProjectId.value = showOptions.value.projectId ?? null
|
||||
await nextTick()
|
||||
if (!(await loadProjectForModal(!!showOptions.value.projectId))) return
|
||||
resetDownloadState()
|
||||
modalOpen.value = true
|
||||
modal.value.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (!modal.value || !modalOpen.value) return
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function onDownload() {
|
||||
emit('download')
|
||||
}
|
||||
|
||||
function getDefaultProjectDownloadSelection(): ProjectDownloadSelection {
|
||||
return {
|
||||
currentGameVersion: null,
|
||||
currentPlatform: null,
|
||||
selectedVersion: null,
|
||||
selectedPrimaryFile: null,
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultShowOptions(): ResolvedProjectDownloadModalShowOptions {
|
||||
return {
|
||||
projectId: undefined,
|
||||
incompatibleGameVersions: [],
|
||||
incompatibleLoaders: [],
|
||||
}
|
||||
}
|
||||
|
||||
function clearCloseStateResetTimeout() {
|
||||
if (!closeStateResetTimeout) return
|
||||
clearTimeout(closeStateResetTimeout)
|
||||
closeStateResetTimeout = null
|
||||
}
|
||||
|
||||
async function waitForCloseStateReset() {
|
||||
if (!closeStateResetTimeout) return
|
||||
await new Promise((resolve) => setTimeout(resolve, MODAL_CLOSE_STATE_RESET_MS))
|
||||
}
|
||||
|
||||
async function loadProjectForModal(forceRefetch: boolean) {
|
||||
if (!routeProjectId.value) return false
|
||||
if (!forceRefetch && projectRaw.value) return true
|
||||
|
||||
const { data } = await refetchProject()
|
||||
return !!data
|
||||
}
|
||||
|
||||
function resetDownloadState() {
|
||||
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
|
||||
dependencyDownloadFiles.value = []
|
||||
downloadProjectResetKey.value += 1
|
||||
}
|
||||
|
||||
function openFromHash() {
|
||||
if (
|
||||
!props.useRouteHash ||
|
||||
!modal.value ||
|
||||
modalOpen.value ||
|
||||
showProjectId.value ||
|
||||
route.hash !== '#download'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
debug('hash #download watch fired, opening modal')
|
||||
show()
|
||||
}
|
||||
|
||||
if (
|
||||
props.useRouteHash &&
|
||||
(route.hash === '#download' ||
|
||||
route.query.version !== undefined ||
|
||||
route.query.loader !== undefined)
|
||||
) {
|
||||
debug('eager loadVersions from setup', {
|
||||
hash: route.hash,
|
||||
version: route.query.version,
|
||||
loader: route.query.loader,
|
||||
loading: versionsV3Loading.value,
|
||||
})
|
||||
versionsEnabled.value = true
|
||||
}
|
||||
|
||||
watch(modal, openFromHash)
|
||||
watch(() => route.hash, openFromHash)
|
||||
watch(routeProjectId, () => {
|
||||
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
|
||||
dependencyDownloadFiles.value = []
|
||||
downloadProjectResetKey.value += 1
|
||||
})
|
||||
|
||||
onUnmounted(clearCloseStateResetTimeout)
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
CodeIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EllipsisVerticalIcon,
|
||||
ExternalIcon,
|
||||
EyeOffIcon,
|
||||
LinkIcon,
|
||||
LoaderCircleIcon,
|
||||
ScaleIcon,
|
||||
ShieldCheckIcon,
|
||||
@@ -113,51 +112,6 @@ const isProjectApproved = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const quickActions = computed<OverflowMenuOption[]>(() => {
|
||||
const actions: OverflowMenuOption[] = []
|
||||
|
||||
const sourceUrl = props.item.project.link_urls?.['source']?.url
|
||||
if (sourceUrl) {
|
||||
actions.push({
|
||||
id: 'view-source',
|
||||
action: () => {
|
||||
window.open(sourceUrl, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
actions.push(
|
||||
{
|
||||
id: 'copy-link',
|
||||
action: () => {
|
||||
const base = window.location.origin
|
||||
const reportUrl = `${base}/moderation/technical-review/${props.item.project.id}`
|
||||
navigator.clipboard.writeText(reportUrl).then(() => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Technical Review link copied',
|
||||
text: 'The link to this review has been copied to your clipboard.',
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'copy-id',
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(props.item.project.id).then(() => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Project ID copied',
|
||||
text: 'The ID of this project has been copied to your clipboard.',
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return actions
|
||||
})
|
||||
|
||||
const isLoadingStatusAction = ref(false)
|
||||
const projectStatusActions = computed<OverflowMenuOption[]>(() => [
|
||||
{
|
||||
@@ -1020,6 +974,16 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyId() {
|
||||
navigator.clipboard.writeText(props.item.project.id).then(() => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Project ID copied',
|
||||
text: 'The ID of this project has been copied to your clipboard.',
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1105,25 +1069,31 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-base text-secondary">{{ formattedDate }}</span>
|
||||
<ButtonStyled circular>
|
||||
<OverflowMenu :options="quickActions" class="!shadow-none">
|
||||
<template #default>
|
||||
<EllipsisVerticalIcon class="size-4" />
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon />
|
||||
<span class="hidden sm:inline">Copy ID</span>
|
||||
</template>
|
||||
<template #copy-link>
|
||||
<LinkIcon />
|
||||
<span class="hidden sm:inline">Copy link</span>
|
||||
</template>
|
||||
<template #view-source>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled v-if="props.item.project.link_urls?.['source']?.url" circular>
|
||||
<a
|
||||
v-tooltip="'Open sources in new tab'"
|
||||
:href="props.item.project.link_urls?.['source']?.url"
|
||||
target="_blank"
|
||||
>
|
||||
<CodeIcon />
|
||||
<span class="hidden sm:inline">View source</span>
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button v-tooltip="'Copy ID'" @click="copyId">
|
||||
<ClipboardCopyIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="'Open in new tab'"
|
||||
:href="`/moderation/technical-review/${props.item.project.id}`"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { FolderSearchIcon, StarIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
NewModal,
|
||||
Table,
|
||||
type TableColumn,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'modpack-scan-modal.title',
|
||||
defaultMessage: 'Modpack Scan ({scanned}/{total} Files)',
|
||||
},
|
||||
scanAllFiles: {
|
||||
id: 'modpack-scan-modal.scan-all-files',
|
||||
defaultMessage: 'Scan All Files',
|
||||
},
|
||||
packFileName: {
|
||||
id: 'modpack-scan-modal.pack-file-name',
|
||||
defaultMessage: 'Pack File Name',
|
||||
},
|
||||
newFiles: {
|
||||
id: 'modpack-scan-modal.new-files',
|
||||
defaultMessage: 'New Files',
|
||||
},
|
||||
newGroups: {
|
||||
id: 'modpack-scan-modal.new-groups',
|
||||
defaultMessage: 'New Groups',
|
||||
},
|
||||
loadingVersions: {
|
||||
id: 'modpack-scan-modal.loading-versions',
|
||||
defaultMessage: 'Loading versions...',
|
||||
},
|
||||
noFiles: {
|
||||
id: 'modpack-scan-modal.no-files',
|
||||
defaultMessage: 'No files found.',
|
||||
},
|
||||
notScanned: {
|
||||
id: 'modpack-scan-modal.not-scanned',
|
||||
defaultMessage: 'Not scanned',
|
||||
},
|
||||
scanning: {
|
||||
id: 'modpack-scan-modal.scanning',
|
||||
defaultMessage: 'Scanning...',
|
||||
},
|
||||
failed: {
|
||||
id: 'modpack-scan-modal.failed',
|
||||
defaultMessage: 'Failed',
|
||||
},
|
||||
overrideFiles: {
|
||||
id: 'modpack-scan-modal.override-files',
|
||||
defaultMessage: 'Override Files ({count})',
|
||||
},
|
||||
loadVersionsError: {
|
||||
id: 'modpack-scan-modal.load-versions-error',
|
||||
defaultMessage: 'Failed to load versions: {error}',
|
||||
},
|
||||
scanError: {
|
||||
id: 'modpack-scan-modal.scan-error',
|
||||
defaultMessage: 'Some files failed to scan: {error}',
|
||||
},
|
||||
})
|
||||
|
||||
type ScanTableColumn = 'filename' | 'newFiles' | 'newGroups'
|
||||
|
||||
type ScanRow = {
|
||||
id: string
|
||||
filename: string
|
||||
primary: boolean
|
||||
scan?: Labrinth.Attribution.Internal.FileScanResponse
|
||||
isScanning: boolean
|
||||
error?: string
|
||||
newFiles?: number
|
||||
newGroups?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
project_id: string
|
||||
}>()
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const rows = ref<ScanRow[]>([])
|
||||
const isLoadingVersions = ref(false)
|
||||
const isScanning = ref(false)
|
||||
const versionLoadError = ref<string | null>(null)
|
||||
const scanError = ref<string | null>(null)
|
||||
const requestId = ref(0)
|
||||
const scanRequestId = ref(0)
|
||||
|
||||
const columns = computed<TableColumn<ScanTableColumn>[]>(() => [
|
||||
{ key: 'filename', label: formatMessage(messages.packFileName), width: '60%' },
|
||||
{ key: 'newFiles', label: formatMessage(messages.newFiles), align: 'center', width: '20%' },
|
||||
{ key: 'newGroups', label: formatMessage(messages.newGroups), align: 'center', width: '20%' },
|
||||
])
|
||||
|
||||
const scannedCount = computed(() => rows.value.filter((row) => row.scan || row.error).length)
|
||||
const isBusy = computed(() => isLoadingVersions.value || isScanning.value)
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null && 'data' in error) {
|
||||
const data = (error as { data?: { description?: string } }).data
|
||||
if (data?.description) {
|
||||
return data.description
|
||||
}
|
||||
}
|
||||
|
||||
return String(error)
|
||||
}
|
||||
|
||||
async function fetchAllVersions() {
|
||||
const currentRequestId = ++requestId.value
|
||||
isLoadingVersions.value = true
|
||||
versionLoadError.value = null
|
||||
scanError.value = null
|
||||
rows.value = []
|
||||
|
||||
try {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(props.project_id)
|
||||
if (currentRequestId !== requestId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
rows.value = versions
|
||||
.flatMap((version) => version.files)
|
||||
.filter((file): file is Labrinth.Versions.v2.VersionFile & { id: string } => Boolean(file.id))
|
||||
.map((file) => ({
|
||||
id: file.id,
|
||||
filename: file.filename,
|
||||
primary: file.primary,
|
||||
isScanning: false,
|
||||
newFiles: undefined,
|
||||
newGroups: undefined,
|
||||
}))
|
||||
} catch (error) {
|
||||
if (currentRequestId === requestId.value) {
|
||||
versionLoadError.value = formatMessage(messages.loadVersionsError, {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (currentRequestId === requestId.value) {
|
||||
isLoadingVersions.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllScans() {
|
||||
if (isBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentScanRequestId = ++scanRequestId.value
|
||||
isScanning.value = true
|
||||
scanError.value = null
|
||||
rows.value = rows.value.map((row) => ({
|
||||
...row,
|
||||
scan: undefined,
|
||||
isScanning: false,
|
||||
error: undefined,
|
||||
newFiles: undefined,
|
||||
newGroups: undefined,
|
||||
}))
|
||||
|
||||
try {
|
||||
for (const row of rows.value) {
|
||||
if (currentScanRequestId !== scanRequestId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
row.isScanning = true
|
||||
try {
|
||||
const scan = await client.labrinth.attribution_internal.scanFile(row.id)
|
||||
if (currentScanRequestId !== scanRequestId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
row.scan = scan
|
||||
row.newFiles = scan.new_attribution_files
|
||||
row.newGroups = scan.new_attribution_groups
|
||||
} catch (error) {
|
||||
if (currentScanRequestId !== scanRequestId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
row.error = getErrorMessage(error)
|
||||
scanError.value = formatMessage(messages.scanError, { error: row.error })
|
||||
} finally {
|
||||
row.isScanning = false
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (currentScanRequestId === scanRequestId.value) {
|
||||
isScanning.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
scanRequestId.value++
|
||||
isScanning.value = false
|
||||
rows.value = []
|
||||
void fetchAllVersions()
|
||||
modalRef.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modalRef.value?.hide()
|
||||
}
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modalRef"
|
||||
width="60vw"
|
||||
:close-on-click-outside="false"
|
||||
:close-on-esc="false"
|
||||
:disable-close="isBusy"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<span class="text-2xl font-semibold text-contrast">
|
||||
{{
|
||||
formatMessage(messages.title, {
|
||||
scanned: scannedCount,
|
||||
total: rows.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div>
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.scanAllFiles)"
|
||||
:disabled="isBusy || rows.length === 0"
|
||||
@click="fetchAllScans"
|
||||
>
|
||||
<FolderSearchIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full">
|
||||
<div
|
||||
v-if="versionLoadError || scanError"
|
||||
class="mb-3 rounded-xl bg-highlight-red p-3 text-red"
|
||||
>
|
||||
{{ versionLoadError || scanError }}
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
row-key="id"
|
||||
:row-below-visible="
|
||||
(row) => Boolean(row.scan?.scanned_file_names && row.scan.scanned_file_names.length > 0)
|
||||
"
|
||||
table-min-width="42rem"
|
||||
>
|
||||
<template #cell-filename="{ row }">
|
||||
<div class="flex min-w-0 items-center gap-1 text-contrast">
|
||||
<StarIcon v-if="row.primary" class="size-4 shrink-0" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">{{ row.filename }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-newFiles="{ row }">
|
||||
<span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span>
|
||||
<span v-else-if="row.error" v-tooltip="row.error" class="text-red">
|
||||
{{ formatMessage(messages.failed) }}
|
||||
</span>
|
||||
<span v-else-if="row.scan">{{ row.scan.new_attribution_files }}</span>
|
||||
<span v-else>{{ formatMessage(messages.notScanned) }}</span>
|
||||
</template>
|
||||
<template #cell-newGroups="{ row }">
|
||||
<span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span>
|
||||
<span v-else-if="row.error" v-tooltip="row.error" class="text-red">
|
||||
{{ formatMessage(messages.failed) }}
|
||||
</span>
|
||||
<span v-else-if="row.scan">{{ row.scan.new_attribution_groups }}</span>
|
||||
<span v-else>{{ formatMessage(messages.notScanned) }}</span>
|
||||
</template>
|
||||
<template #row-below="{ row }">
|
||||
<div class="border-0 border-t border-solid border-surface-4 px-4 py-3">
|
||||
<details>
|
||||
<summary>
|
||||
{{
|
||||
formatMessage(messages.overrideFiles, {
|
||||
count: row.scan?.scanned_file_names.length ?? 0,
|
||||
})
|
||||
}}
|
||||
</summary>
|
||||
<div class="flex flex-wrap gap-1 pt-2">
|
||||
<span
|
||||
v-for="name of row.scan?.scanned_file_names ?? []"
|
||||
:key="name"
|
||||
v-tooltip="name"
|
||||
class="flex items-center gap-1 text-wrap rounded-full bg-button-bg px-2 py-0.5 text-xs font-medium text-contrast"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<div class="flex h-64 items-center justify-center text-secondary">
|
||||
{{ formatMessage(isLoadingVersions ? messages.loadingVersions : messages.noFiles) }}
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
BrowseInstallPlan,
|
||||
BrowseSearchState,
|
||||
CreationFlowContextValue,
|
||||
EnvironmentSearchOverride,
|
||||
FilterValue,
|
||||
PendingServerContentInstall,
|
||||
PendingServerContentInstallType,
|
||||
@@ -160,6 +161,7 @@ export function useServerInstallContent({
|
||||
})
|
||||
|
||||
const serverHideInstalled = ref(false)
|
||||
const serverContentServerOnly = ref(false)
|
||||
const hideSelectedServerInstalls = ref(false)
|
||||
const installingProjectIds = ref<Set<string>>(new Set())
|
||||
const optimisticallyInstalledProjectIds = ref<Set<string>>(new Set())
|
||||
@@ -348,10 +350,6 @@ export function useServerInstallContent({
|
||||
filters.push({ type: 'plugin_loader', option: platform })
|
||||
}
|
||||
|
||||
if (projectType.value?.id === 'mod') {
|
||||
filters.push({ type: 'environment', option: 'server' })
|
||||
}
|
||||
|
||||
if (serverHideInstalled.value && hiddenInstalledProjectIds.value.size > 0) {
|
||||
for (const x of hiddenInstalledProjectIds.value) {
|
||||
filters.push({
|
||||
@@ -383,6 +381,28 @@ export function useServerInstallContent({
|
||||
return filters
|
||||
})
|
||||
|
||||
const showServerOnlyToggle = computed(() => !!serverData.value && projectType.value?.id === 'mod')
|
||||
|
||||
const serverEnvironmentOverride = computed<EnvironmentSearchOverride | undefined>(() => {
|
||||
if (!showServerOnlyToggle.value) return undefined
|
||||
if (serverContentServerOnly.value) {
|
||||
return {
|
||||
mode: 'include',
|
||||
values: [
|
||||
'server_only',
|
||||
'dedicated_server_only',
|
||||
'server_only_client_optional',
|
||||
'client_or_server_prefers_both',
|
||||
'client_or_server',
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'exclude',
|
||||
values: ['client_only', 'singleplayer_only'],
|
||||
}
|
||||
})
|
||||
|
||||
function getCurrentServerInstallType(): BrowseInstallContentType {
|
||||
const type = projectType.value?.id
|
||||
if (type === 'modpack' || type === 'mod' || type === 'plugin' || type === 'datapack') {
|
||||
@@ -737,6 +757,10 @@ export function useServerInstallContent({
|
||||
serverHideInstalled.value = route.query.shi === 'true'
|
||||
}
|
||||
|
||||
if (route.query.so && projectType.value?.id === 'mod') {
|
||||
serverContentServerOnly.value = route.query.so === 'true'
|
||||
}
|
||||
|
||||
watch(serverHideInstalled, (hideInstalled) => {
|
||||
if (hideInstalled) {
|
||||
syncHiddenInstalledProjectIds()
|
||||
@@ -763,6 +787,9 @@ export function useServerInstallContent({
|
||||
serverContentData,
|
||||
serverFilters,
|
||||
serverHideInstalled,
|
||||
serverContentServerOnly,
|
||||
showServerOnlyToggle,
|
||||
serverEnvironmentOverride,
|
||||
hideSelectedServerInstalls,
|
||||
installingProjectIds,
|
||||
optimisticallyInstalledProjectIds,
|
||||
|
||||
@@ -2357,42 +2357,18 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licencováno"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Verze hry: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Chyba: žádné verze hry nebyly nalezeny"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} je k dispozici pouze pro verzi {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} nepodporuje {gameVersion} pro {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Instalovat přes Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Nemáte Modrinth App?"
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platforma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Chyba: žádné platformy nenalezeny"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} je k dispozici pouze pro {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} nepodporuje {platform} pro {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Hledat verze hry..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Hledat verze hry..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Vybrat verzi hry"
|
||||
},
|
||||
|
||||
@@ -1760,45 +1760,21 @@
|
||||
"project.description.title": {
|
||||
"message": "Beskrivelse"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Spille version: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Fejl: ingen spil versioner fundet"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} er kun tilgængelig til {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} understøtter ikke {gameVersion} til {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Installer med Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Har ikke Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Ingen versioner tilgængelig til {gameVersion} og {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Fejl: ingen platforme fundet"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} er kun tilgængelig til {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} understøtter ikke {platform} til {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Søg spil versioner..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Søg spil versioner..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Vælg spil version"
|
||||
},
|
||||
|
||||
@@ -3221,45 +3221,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Lizenziert unter"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Spielversion: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Fehler: Keine Spielversion gefunden"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} ist nur für {version} verfügbar"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} unterstützt {gameVersion} für {platform} nicht"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Mit Modrinth App installieren"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Du hast die Modrinth App nicht?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Keinen versionen verfügbar für {gameVersion} und {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Fehler: Keine Plattform gefunden"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} ist nur für {platform} verfügbar"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} unterstützt {platform} für {gameVersion} nicht"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Spielversionen durchsuchen..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Spielversionen durchuschen..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Spielversion wählen"
|
||||
},
|
||||
|
||||
@@ -3221,45 +3221,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Lizenziert unter"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Spielversion: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Fehler: Keine Spielversion gefunden"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} ist nur für {version} verfügbar"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} unterstützt {gameVersion} für {platform} nicht"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Mit Modrinth App installieren"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Du hast die Modrinth App nicht?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Keine Versionen für {gameVersion} und {platform} verfügbar."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plattform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Fehler: Keine Platform gefunden"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} ist nur für {platform} verfügbar"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} unterstützt {platform} für die {gameVersion} nicht"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Spielversionen durchsuchen..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Spielversionen durchuschen..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Spielversion auswählen"
|
||||
},
|
||||
|
||||
@@ -2687,6 +2687,45 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "Tech review"
|
||||
},
|
||||
"modpack-scan-modal.failed": {
|
||||
"message": "Failed"
|
||||
},
|
||||
"modpack-scan-modal.load-versions-error": {
|
||||
"message": "Failed to load versions: {error}"
|
||||
},
|
||||
"modpack-scan-modal.loading-versions": {
|
||||
"message": "Loading versions..."
|
||||
},
|
||||
"modpack-scan-modal.new-files": {
|
||||
"message": "New Files"
|
||||
},
|
||||
"modpack-scan-modal.new-groups": {
|
||||
"message": "New Groups"
|
||||
},
|
||||
"modpack-scan-modal.no-files": {
|
||||
"message": "No files found."
|
||||
},
|
||||
"modpack-scan-modal.not-scanned": {
|
||||
"message": "Not scanned"
|
||||
},
|
||||
"modpack-scan-modal.override-files": {
|
||||
"message": "Override Files ({count})"
|
||||
},
|
||||
"modpack-scan-modal.pack-file-name": {
|
||||
"message": "Pack File Name"
|
||||
},
|
||||
"modpack-scan-modal.scan-all-files": {
|
||||
"message": "Scan All Files"
|
||||
},
|
||||
"modpack-scan-modal.scan-error": {
|
||||
"message": "Some files failed to scan: {error}"
|
||||
},
|
||||
"modpack-scan-modal.scanning": {
|
||||
"message": "Scanning..."
|
||||
},
|
||||
"modpack-scan-modal.title": {
|
||||
"message": "Modpack Scan ({scanned}/{total} Files)"
|
||||
},
|
||||
"muralpay.account-type.checking": {
|
||||
"message": "Checking"
|
||||
},
|
||||
@@ -3233,44 +3272,80 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licensed"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Game version: {version}"
|
||||
"project.download.additional-files-title": {
|
||||
"message": "Additional files"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Error: no game versions found"
|
||||
"project.download.base-game-version-incompatible-tooltip": {
|
||||
"message": "This game version is incompatible with the base project."
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} is only available for {version}"
|
||||
"project.download.base-loader-incompatible-tooltip": {
|
||||
"message": "This loader is incompatible with the base project."
|
||||
},
|
||||
"project.download.compatible-version-title": {
|
||||
"message": "Compatible version"
|
||||
},
|
||||
"project.download.dependencies-title": {
|
||||
"message": "Dependencies"
|
||||
},
|
||||
"project.download.dependency-already-installed": {
|
||||
"message": "This dependency is already installed"
|
||||
},
|
||||
"project.download.dependency-conflicting": {
|
||||
"message": "This dependency conflicts with another dependency"
|
||||
},
|
||||
"project.download.dependency-download-file": {
|
||||
"message": "Download {filename}"
|
||||
},
|
||||
"project.download.dependency-download-file-with-size": {
|
||||
"message": "Download {filename} ({size})"
|
||||
},
|
||||
"project.download.dependency-duplicate": {
|
||||
"message": "This dependency is already included"
|
||||
},
|
||||
"project.download.dependency-missing-version": {
|
||||
"message": "This dependency version is unavailable"
|
||||
},
|
||||
"project.download.dependency-no-compatible-version": {
|
||||
"message": "No compatible version is available for this dependency"
|
||||
},
|
||||
"project.download.dependency-quilt-fabric-api": {
|
||||
"message": "Fabric API is skipped for Quilt"
|
||||
},
|
||||
"project.download.dependency-unavailable": {
|
||||
"message": "This dependency cannot be downloaded"
|
||||
},
|
||||
"project.download.download-version": {
|
||||
"message": "Download {version}"
|
||||
},
|
||||
"project.download.duplicate-dependencies-hidden": {
|
||||
"message": "Duplicate dependencies are hidden"
|
||||
},
|
||||
"project.download.file-unavailable": {
|
||||
"message": "This file cannot be downloaded"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} does not support {gameVersion} for {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Install with Modrinth App"
|
||||
"project.download.install-with-app-description": {
|
||||
"message": "Automatically install the correct version and dependencies."
|
||||
},
|
||||
"project.download.manually": {
|
||||
"message": "Download manually"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Don't have Modrinth App?"
|
||||
},
|
||||
"project.download.no-game-versions-found": {
|
||||
"message": "No game versions found"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "No versions available for {gameVersion} and {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Error: no platforms found"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} is only available for {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} does not support {platform} for {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Search game versions..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Search game versions..."
|
||||
"message": "Select game version"
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Select game version"
|
||||
@@ -3278,12 +3353,27 @@
|
||||
"project.download.select-platform": {
|
||||
"message": "Select platform"
|
||||
},
|
||||
"project.download.selected-version-download-all": {
|
||||
"message": "Download all (.zip)"
|
||||
},
|
||||
"project.download.selected-version-downloading": {
|
||||
"message": "Downloading... ({current}/{total})"
|
||||
},
|
||||
"project.download.selected-version-failed-text": {
|
||||
"message": "One or more version files could not be downloaded. Please try again."
|
||||
},
|
||||
"project.download.selected-version-failed-title": {
|
||||
"message": "Could not download version"
|
||||
},
|
||||
"project.download.show-all-versions": {
|
||||
"message": "Show all versions"
|
||||
},
|
||||
"project.download.title": {
|
||||
"message": "Download {title}"
|
||||
},
|
||||
"project.download.unknown-loader": {
|
||||
"message": "Unknown loader"
|
||||
},
|
||||
"project.environment.migration-no-permission.message": {
|
||||
"message": "We've just overhauled the Environments system on Modrinth and new options are now available. You don't have permission to modify these settings, but please let another member of the project know that the environment metadata needs to be verified."
|
||||
},
|
||||
@@ -4764,7 +4854,7 @@
|
||||
"message": "View version"
|
||||
},
|
||||
"version.download.download-dependency": {
|
||||
"message": "Download dependency"
|
||||
"message": "Select a version to download"
|
||||
},
|
||||
"version.download.no-primary-file": {
|
||||
"message": "Error: No primary file found"
|
||||
@@ -4775,6 +4865,9 @@
|
||||
"version.download.required-resource-pack": {
|
||||
"message": "Required resource pack"
|
||||
},
|
||||
"version.download.version": {
|
||||
"message": "Download {version} ({size})"
|
||||
},
|
||||
"version.edit.button": {
|
||||
"message": "Edit"
|
||||
},
|
||||
|
||||
@@ -3221,45 +3221,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Con licencia"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versión del juego: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Error: no se encontraron versiones del juego"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} solo está disponible para {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} no soporta {gameVersion} para {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Instalar con la Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "¿No tienes la Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "No hay versiones disponibles para {gameVersion} y {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plataforma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Error: no se encontraron plataformas"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} solo está disponible para {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} no soporta {platform} para {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Buscar versiones del juego..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Buscar versiones del juego..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Selecciona la versión del juego"
|
||||
},
|
||||
|
||||
@@ -3101,45 +3101,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Con licencia"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versión del juego: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Error: no se han encontrado versiones del juego"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} solo está disponible para {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} no está soportado para la versión {gameVersion} de {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Instalar con la aplicación Modrinth"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "¿No tienes la aplicación Modrinth?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "No hay versiones disponibles para {gameVersion} y {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plataforma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Error: plataforma no encontrada"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} olo está disponible para {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} no está soportado para {platform} en la {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Buscar versiones del juego..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Buscar versiones del juego..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Selecciona la versión del juego"
|
||||
},
|
||||
|
||||
@@ -2408,45 +2408,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Lisensiyang"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Bersiyon ng laro: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Error: walang mahanap na mga bersiyon ng laro"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "Ang {title} ay magagamit lamang sa {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "Hindi masuporta ng {title} ang {gameVersion} para sa {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "I-install gamit ang Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Wala kang Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Walang bersiyong magagamit para sa {gameVersion} at {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plataporma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Error: walang mahanap na platform"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "Ang {title} ay magagamit lamang sa {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "Hindi masuport ng {title} ang {platform} para sa {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Maghanap ng mga bersiyon ng laro..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Maghanap ng mga bersiyon ng laro..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Piliin ang lahat ng bersiyon ng laro"
|
||||
},
|
||||
|
||||
@@ -3221,45 +3221,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "License"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Version du jeu : {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Erreur : pas de versions du jeu trouvées"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} n'est disponible que pour {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} ne supporte pas {gameVersion} pour {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Installer avec Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Vous n'avez pas Modrinth App ?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Aucune version disponible pour {gameVersion} et {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plateforme : {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Erreur : pas de plateformes trouvées"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} est que disponible pour {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} ne supporte pas {platform} pour {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Recherchez une version du jeu..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Recherchez une version du jeu..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Sélectionnez la version du jeu"
|
||||
},
|
||||
|
||||
@@ -2027,45 +2027,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "ברישיון"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "גרסת המשחק: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "שגיאה: לא נמצאו גרסאות משחק"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} זמין עבור {version} בלבד"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} אינו תומך ב-{gameVersion} עבור {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "התקן באמצעות Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "אין לכם את אפליקציית Modrinth?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "אין גרסאות זמינות עבור {gameVersion} ו-{platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "פלטפורמה: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "שגיאה: לא נמצאו פלטפורמות"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} זמין עבור {platform} בלבד"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} אינו תומך ב-{platform} עבור {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "חיפוש גרסאות משחק..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "חיפוש גרסאות משחק..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "בחירת גרסת משחק"
|
||||
},
|
||||
|
||||
@@ -2918,45 +2918,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licensz:"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Játékverzió: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Hiba: nem található játékverzió"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} csak erre a verzióra érthető el: {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "A(z) {title} nem támogatja ezt a verziót: {platform} {gameVersion}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Letöltés a Modrinth Appal"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Nincs meg a Modrinth App? "
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nem érhető el verzió ehhez: {platform} {gameVersion}"
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Hiba: nem található platform"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "A(z) {title} csak erre érthető el: {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "A(z) {title} nem támogatja ezt a verziót: {platform} {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Játékverziók keresése..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Játékverziók keresése..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Játékverzió kiválasztása"
|
||||
},
|
||||
|
||||
@@ -2423,45 +2423,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Berlisensi"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versi permainan: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Kesalahan: tidak ada versi permainan yang ditemukan"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} hanya tersedia untuk {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} tidak mendukung {gameVersion} untuk {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Pasang dengan Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Tidak memiliki Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Tidak ada versi tersedia untuk {gameVersion} dan {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Kesalahan: tidak ada platform yang ditemukan"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} hanya tersedia untuk {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} tidak mendukung {platform} untuk {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Cari versi permainan..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Cari versi permainan..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Pilih versi permainan"
|
||||
},
|
||||
|
||||
@@ -3212,45 +3212,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licenza"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versione del gioco: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Errore: nessuna versione del gioco trovata"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} è disponibile solo per {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} non supporta {platform} per {gameVersion}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Installa con Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Non hai Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nessuna versione disponibile per {gameVersion} e {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Piattaforma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Errore: nessuna piattaforma trovata"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} è disponibile solo per {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} non supporta {platform} per {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Cerca versioni del gioco..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Cerca tra le versioni del gioco..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Seleziona la versione del gioco"
|
||||
},
|
||||
|
||||
@@ -2729,45 +2729,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "ライセンス"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "ゲームバージョン: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "エラー: ゲームバージョンが見つかりません"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title}は{version}でのみ利用可能です"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title}は{platform}の{gameVersion}に対応していません"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Modrinth Appでインストール"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Modrinth Appをお持ちでないですか?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "{gameVersion}および{platform}向けのバージョンは利用できません。"
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "プラットフォーム: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "エラー: プラットフォームが見つかりません"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title}は{platform}でのみ利用可能です"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title}は{gameVersion}の{platform}に対応していません"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "ゲームバージョンを検索…"
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "ゲームバージョンを検索…"
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "ゲームバージョンを選択"
|
||||
},
|
||||
|
||||
@@ -2543,45 +2543,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "라이선스"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "게임 버전: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "오류: 게임 버전을 찾을 수 없습니다"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title}은(는) {version}에서만 사용할 수 있습니다"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title}은(는) {platform}용 {gameVersion}을(를) 지원하지 않습니다"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Modrinth App으로 설치"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Modrinth 앱을 설치하지 않으셨나요?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "{gameVersion} 및 {platform}용 버전이 없습니다."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "플랫폼: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "오류: 플랫폼을 찾을 수 없음"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} 은(는) {platform} 에서만 사용할 수 있습니다"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} 은(는) {gameVersion} 용 {platform} 을(를) 지원하지 않습니다."
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "게임 버전 검색..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "게임 버전 검색..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "게임 버전 선택"
|
||||
},
|
||||
|
||||
@@ -2918,45 +2918,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Berlesen"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versi permainan: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Ralat: tiada versi permainan dijumpai"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} hanya tersedia untuk {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} tidak menyokong {gameVersion} untuk {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Pasang dengan Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Tidak ada Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Tiada versi tersedia untuk {gameVersion} dan {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Ralat: tiada platform dijumpai"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} hanya tersedia untuk {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} tidak menyokong {platform} untuk {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Cari versi permainan..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Cari versi permainan..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Pilih versi permainan"
|
||||
},
|
||||
|
||||
@@ -2792,45 +2792,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Gelicentieerd"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Spel versie: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Error: geen spel-versies gevonden"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} is alleen beschikbaar voor {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} steunt niet {gameVersion} voor {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Installeer met de Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Heb je de Modrinth App niet?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Geen versies beschikbaar voor {gameVersion} en {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Error: geen platformen gevonden"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} is alleen beschikbaar voor {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} steunt niet {platform} voor {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Game-versies zoeken..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Zoek game-versies..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Selecteer game-versie"
|
||||
},
|
||||
|
||||
@@ -2249,45 +2249,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Lisensert"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Spillversjon: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Error: ingen spillversjon funnet"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} er bare tilgjengelig for {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} støtter ikke {gameVersion} for {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Installer med Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Har du ikke en Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Ingen versjoner tilgjengelige for {gameVersion} og {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plattform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Error: Ingen plattformer funnet"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} er bare tilgjengelig for {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} støtter ikke {platform} for {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Søk opp spillversjoner..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Søk opp spillversjoner..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Velg spillversjon"
|
||||
},
|
||||
|
||||
@@ -3215,45 +3215,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licencja"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Wersja gry: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Błąd: nie znaleziono wersji gry"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} jest dostępny tylko dla {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} nie obsługuje {gameVersion} dla {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Zainstaluj za pomocą Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Nie masz Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Brak dostępnych wersji dla {gameVersion} i {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platforma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Błąd: nie znaleziono żadnych platform"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} jest dostępny tylko dla {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} nie obsługuje {platform} dla {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Szukaj wersji gry..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Szukaj wersji gry..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Wybierz wersję gry"
|
||||
},
|
||||
|
||||
@@ -3212,45 +3212,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licença"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versão do jogo: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Erro: nenhuma versão do jogo encontrada"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} só está disponível para {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} não suporta a {gameVersion} para {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Instalar com o Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Não tem o Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nenhuma versão disponível para {gameVersion} e {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plataforma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Erro: nenhuma plataforma encontrada"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} só está disponível para {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} não suporta {platform} para {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Buscar versões do jogo..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Buscar versões do jogo..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Selecionar versão do jogo"
|
||||
},
|
||||
|
||||
@@ -2222,45 +2222,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licenciado"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versão do jogo: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Erro: nenhuma versão do jogo encontrada"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} apenas está disponível para {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} não suporta {gameVersion} para {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Instala com a Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Não tens a Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nenhuma versão disponível para {gameVersion} e {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plataforma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Erro: nenhuma plataforma encontrada"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} apenas está disponível para {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} não suporta {platform} para {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Procurar versões do jogo..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Procurar versões do jogo..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Selecionar versão do jogo"
|
||||
},
|
||||
|
||||
@@ -1415,45 +1415,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licență"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versiunea jocului: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Eroare: nu s-au găsit versiuni ale jocului"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} este disponibil doar pentru {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} nu este compatibil cu {gameVersion} pentru {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Instalează cu Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Nu ai Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nu există versiuni disponibile pentru {gameVersion} și {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platformă: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Eroare: nu s-au găsit platforme"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} este disponibil doar pentru {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} nu este compatibil cu {platform} pentru {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Caută versiuni ale jocului..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Caută versiuni ale jocului..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Selectează versiunea jocului"
|
||||
},
|
||||
|
||||
@@ -3215,45 +3215,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Лицензия"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Версия игры: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Ошибка: версии игры не найдены"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} доступен только для {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} не поддерживает {gameVersion} для {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Скачать в Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "У вас нет Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Нет версий, доступных для {gameVersion} и {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Платформа: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Ошибка: платформы не найдены"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} доступен только для {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} не поддерживает {platform} для {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Поиск версий игры..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Поиск версий игры..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Выбрать версию игры"
|
||||
},
|
||||
|
||||
@@ -2804,45 +2804,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licensierad"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Spelversion: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Fel: inga spelversioner hittades"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} är bara tillgängligt för {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} stöttar inte {gameVersion} för {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Installera med Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Har du inte Modrinth appen?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Inga versioner finns tillgängliga för {gameVersion} och {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plattform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Fel: ingen plattform hittades"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} är bara tillgängligt för {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} stöttar inte {platform} för {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Sök spelversioner..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Sök spelversioner..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Välj spelversion"
|
||||
},
|
||||
|
||||
@@ -3161,45 +3161,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Lisans:"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Oyun sürümü: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Hata: oyun sürümü bulunamadı"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} sadece {version} sürümünde mevcut"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title}, {platform} için {gameVersion} sürümünü desteklemiyor"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Modrinth App ile yükle"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Modrinth App Yok Mu?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "{gameVersion} ve {platform} için sürüm mevcut değil."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Hata: platform bulunamadı"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} sadece {platform} için mevcut"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title}, {gameVersion} sürümünde {platform} desteklemiyor"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Oyun sürümü ara..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Oyun sürümü ara..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Oyun sürümü seç"
|
||||
},
|
||||
|
||||
@@ -2849,45 +2849,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Ліцензія:"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Версія гри: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Помилка: версію гри не знайдено"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "«{title}» доступно лише для {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "«{title}» не підтримує {gameVersion} для {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Установити через Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Не маєте Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Немає доступних версій для {gameVersion} та {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Платформа: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Помилка: платформи не знайдено"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} доступне лише для {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} не підтримує {platform} для {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Пошук версій гри…"
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Пошук версій гри…"
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Виберіть версію гри"
|
||||
},
|
||||
|
||||
@@ -3065,45 +3065,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Giấy phép"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Phiên bản game: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Lỗi: không tìm thấy phiên bản game"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} chỉ khả dụng cho {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} không hỗ trợ {gameVersion} cho {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Cài đặt bằng Ứng dụng Modrinth"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Chưa có Ứng dụng Modrinth?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Không có phiên bản khả dụng cho {gameVersion} và {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Nền tảng: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Lỗi: không tìm thấy nền tảng"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} chỉ khả dụng cho {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} không hỗ trợ {platform} cho {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Tìm kiếm phiên bản game..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Tìm kiếm phiên bản game..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Chọn phiên bản game"
|
||||
},
|
||||
|
||||
@@ -3215,45 +3215,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "许可证"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "游戏版本:{version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "错误:没有合适的游戏版本"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title}仅适用于{version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} 不支持 {platform} {gameVersion}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "使用 Modrinth App 安装"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "还没有 Modrinth App 吗?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "没有支持 {platform} {gameVersion} 的版本。"
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "平台:{platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "错误:没有合适的平台"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} 仅适用于 {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} 不支持 {gameVersion} 的 {platform}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "搜索游戏版本…"
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "搜索游戏版本…"
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "选择游戏版本"
|
||||
},
|
||||
|
||||
@@ -3221,45 +3221,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "授權條款"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "遊戲版本:{version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "錯誤:找不到適合的遊戲版本"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} 僅適用於 {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} 不支援 {platform} {gameVersion}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "使用 Modrinth App 安裝"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "還沒有 Modrinth App 嗎?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "{platform} {gameVersion} 沒有可用的版本。"
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "平臺:{platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "錯誤:找不到平臺"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} 僅適用於 {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} 不支援 {platform} {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "搜尋遊戲版本..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "搜尋遊戲版本..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "搜尋遊戲版本"
|
||||
},
|
||||
|
||||
@@ -88,339 +88,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NewModal
|
||||
<ProjectDownloadModal
|
||||
ref="downloadModal"
|
||||
:on-show="
|
||||
() => {
|
||||
debug('on-show fired')
|
||||
loadVersions()
|
||||
navigateTo({ query: route.query, hash: '#download' }, { replace: true })
|
||||
}
|
||||
"
|
||||
:on-hide="
|
||||
() => {
|
||||
navigateTo({ query: route.query, hash: '' }, { replace: true })
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #title>
|
||||
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
|
||||
<div class="truncate text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.downloadTitle, { title: project.title }) }}
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="mx-auto flex max-w-[44rem] flex-col gap-4 md:w-[30rem]">
|
||||
<div
|
||||
v-if="
|
||||
project.project_type !== 'plugin' ||
|
||||
project.loaders.some((x) => !tags.loaderData.allPluginLoaders.includes(x))
|
||||
"
|
||||
class="modrinth-app-section contents"
|
||||
>
|
||||
<div class="mx-auto flex w-fit flex-col">
|
||||
<ButtonStyled color="brand">
|
||||
<a
|
||||
class="w-fit"
|
||||
:href="`modrinth://mod/${project.slug}`"
|
||||
@click="() => installWithApp()"
|
||||
>
|
||||
<ModrinthIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.installWithModrinthApp) }}
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<Accordion ref="getModrinthAppAccordion">
|
||||
<nuxt-link
|
||||
class="mt-2 flex justify-center text-brand-blue hover:underline"
|
||||
to="/app"
|
||||
>
|
||||
{{ formatMessage(messages.dontHaveModrinthApp) }}
|
||||
</nuxt-link>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 px-4">
|
||||
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
||||
<span class="flex-shrink-0 text-sm font-semibold text-secondary">
|
||||
{{ formatMessage(commonMessages.orLabel) }}
|
||||
</span>
|
||||
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto flex w-fit flex-col gap-2">
|
||||
<ButtonStyled v-if="project.game_versions.length === 1">
|
||||
<div class="disabled button-like">
|
||||
<GameIcon aria-hidden="true" />
|
||||
{{
|
||||
currentGameVersion
|
||||
? formatMessage(messages.gameVersionLabel, { version: currentGameVersion })
|
||||
: formatMessage(messages.gameVersionError)
|
||||
}}
|
||||
<InfoIcon
|
||||
v-tooltip="
|
||||
formatMessage(messages.gameVersionTooltip, {
|
||||
title: project.title,
|
||||
version: currentGameVersion,
|
||||
})
|
||||
"
|
||||
class="ml-auto size-5"
|
||||
/>
|
||||
</div>
|
||||
</ButtonStyled>
|
||||
<Accordion
|
||||
v-else
|
||||
ref="gameVersionAccordion"
|
||||
class="accordion-with-bg"
|
||||
@on-open="
|
||||
() => {
|
||||
if (platformAccordion) {
|
||||
platformAccordion.close()
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #title>
|
||||
<GameIcon aria-hidden="true" />
|
||||
{{
|
||||
currentGameVersion
|
||||
? formatMessage(messages.gameVersionLabel, { version: currentGameVersion })
|
||||
: formatMessage(messages.selectGameVersion)
|
||||
}}
|
||||
</template>
|
||||
<label for="game-versions-filtering" hidden>{{
|
||||
formatMessage(messages.searchGameVersionsLabel)
|
||||
}}</label>
|
||||
<StyledInput
|
||||
id="game-versions-filtering"
|
||||
ref="gameVersionFilterInput"
|
||||
v-model="versionFilter"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.searchGameVersions)"
|
||||
wrapper-class="mb-2 w-full"
|
||||
/>
|
||||
<ScrollablePanel :class="project.game_versions.length > 4 ? 'h-[15rem]' : ''">
|
||||
<ButtonStyled
|
||||
v-for="gameVersion in project.game_versions
|
||||
.filter(
|
||||
(x) =>
|
||||
(versionFilter && x.includes(versionFilter)) ||
|
||||
(!versionFilter && (showAllVersions || isReleaseGameVersion(x))),
|
||||
)
|
||||
.slice()
|
||||
.reverse()"
|
||||
:key="gameVersion"
|
||||
:color="currentGameVersion === gameVersion ? 'brand' : 'standard'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
!possibleGameVersions.includes(gameVersion)
|
||||
? formatMessage(messages.gameVersionUnsupportedTooltip, {
|
||||
title: project.title,
|
||||
gameVersion: gameVersion,
|
||||
platform: currentPlatformText,
|
||||
})
|
||||
: null
|
||||
"
|
||||
:class="{
|
||||
'looks-disabled !text-brand-red':
|
||||
!possibleGameVersions.includes(gameVersion),
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
userSelectedGameVersion = gameVersion
|
||||
gameVersionAccordion.close()
|
||||
if (!currentPlatform && platformAccordion) {
|
||||
platformAccordion.open()
|
||||
}
|
||||
|
||||
navigateTo(
|
||||
{
|
||||
query: {
|
||||
...route.query,
|
||||
...(userSelectedGameVersion && {
|
||||
version: userSelectedGameVersion,
|
||||
}),
|
||||
...(userSelectedPlatform && {
|
||||
loader: userSelectedPlatform,
|
||||
}),
|
||||
},
|
||||
hash: route.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ gameVersion }}
|
||||
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</ScrollablePanel>
|
||||
<Checkbox
|
||||
v-if="showVersionsCheckbox"
|
||||
v-model="showAllVersions"
|
||||
class="mx-1"
|
||||
:label="formatMessage(messages.showAllVersions)"
|
||||
:disabled="!!versionFilter"
|
||||
/>
|
||||
</Accordion>
|
||||
<ButtonStyled
|
||||
v-if="project.loaders.length === 1 && project.project_type !== 'resourcepack'"
|
||||
>
|
||||
<div class="disabled button-like">
|
||||
<WrenchIcon aria-hidden="true" />
|
||||
{{
|
||||
currentPlatform
|
||||
? formatMessage(messages.platformLabel, {
|
||||
platform: currentPlatformText,
|
||||
})
|
||||
: formatMessage(messages.platformError)
|
||||
}}
|
||||
<InfoIcon
|
||||
v-tooltip="
|
||||
formatMessage(messages.platformTooltip, {
|
||||
title: project.title,
|
||||
platform: currentPlatformText,
|
||||
})
|
||||
"
|
||||
class="ml-auto size-5"
|
||||
/>
|
||||
</div>
|
||||
</ButtonStyled>
|
||||
<Accordion
|
||||
v-else-if="project.project_type !== 'resourcepack'"
|
||||
ref="platformAccordion"
|
||||
class="accordion-with-bg"
|
||||
@on-open="
|
||||
() => {
|
||||
if (gameVersionAccordion) {
|
||||
gameVersionAccordion.close()
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #title>
|
||||
<WrenchIcon aria-hidden="true" />
|
||||
{{
|
||||
currentPlatform
|
||||
? formatMessage(messages.platformLabel, {
|
||||
platform: currentPlatformText,
|
||||
})
|
||||
: formatMessage(messages.selectPlatform)
|
||||
}}
|
||||
</template>
|
||||
<ScrollablePanel :class="project.loaders.length > 4 ? 'h-[15rem]' : ''">
|
||||
<ButtonStyled
|
||||
v-for="platform in project.loaders.slice().reverse()"
|
||||
:key="platform"
|
||||
:color="currentPlatform === platform ? 'brand' : 'standard'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
!possiblePlatforms.includes(platform)
|
||||
? formatMessage(messages.platformUnsupportedTooltip, {
|
||||
title: project.title,
|
||||
platform: currentPlatformText,
|
||||
gameVersion: currentGameVersion,
|
||||
})
|
||||
: null
|
||||
"
|
||||
:class="{
|
||||
'looks-disabled !text-brand-red': !possiblePlatforms.includes(platform),
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
userSelectedPlatform = platform
|
||||
|
||||
platformAccordion.close()
|
||||
if (!currentGameVersion && gameVersionAccordion) {
|
||||
gameVersionAccordion.open()
|
||||
}
|
||||
|
||||
navigateTo(
|
||||
{
|
||||
query: {
|
||||
...route.query,
|
||||
...(userSelectedGameVersion && {
|
||||
version: userSelectedGameVersion,
|
||||
}),
|
||||
...(userSelectedPlatform && {
|
||||
loader: userSelectedPlatform,
|
||||
}),
|
||||
},
|
||||
hash: route.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ formatMessage(getTagMessage(platform, 'loader')) }}
|
||||
<CheckIcon v-if="userSelectedPlatform === platform" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</ScrollablePanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
<AutomaticAccordion div class="flex flex-col gap-2">
|
||||
<VersionSummary
|
||||
v-if="filteredRelease"
|
||||
:version="filteredRelease"
|
||||
:decorate-download-url="decorateModalDownloadUrl"
|
||||
@on-download="onDownload"
|
||||
@on-navigate="onVersionNavigate"
|
||||
/>
|
||||
<VersionSummary
|
||||
v-if="filteredBeta"
|
||||
:version="filteredBeta"
|
||||
:decorate-download-url="decorateModalDownloadUrl"
|
||||
@on-download="onDownload"
|
||||
@on-navigate="onVersionNavigate"
|
||||
/>
|
||||
<VersionSummary
|
||||
v-if="filteredAlpha"
|
||||
:version="filteredAlpha"
|
||||
:decorate-download-url="decorateModalDownloadUrl"
|
||||
@on-download="onDownload"
|
||||
@on-navigate="onVersionNavigate"
|
||||
/>
|
||||
<p
|
||||
v-if="
|
||||
currentPlatform &&
|
||||
currentGameVersion &&
|
||||
!filteredRelease &&
|
||||
!filteredBeta &&
|
||||
!filteredAlpha &&
|
||||
!versionsLoading &&
|
||||
versions.length > 0
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.noVersionsAvailable, {
|
||||
gameVersion: currentGameVersion,
|
||||
platform: currentPlatformText,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</AutomaticAccordion>
|
||||
<ServersPromo
|
||||
v-if="flags.showProjectPageDownloadModalServersPromo"
|
||||
:link="`/hosting#plan`"
|
||||
@close="
|
||||
() => {
|
||||
flags.showProjectPageDownloadModalServersPromo = false
|
||||
saveFeatureFlags()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
:project-id="routeProjectId"
|
||||
:download-reason="downloadReason"
|
||||
@download="triggerDownloadAnimation"
|
||||
/>
|
||||
<CollectionCreateModal ref="modal_collection" :project-ids="[project.id]" />
|
||||
<ModpackScanModal ref="scanModal" :project_id="project.id" />
|
||||
|
||||
<div
|
||||
class="new-page sidebar"
|
||||
:class="{
|
||||
@@ -750,13 +426,6 @@
|
||||
tags.staffRoles.includes(auth.user.role) &&
|
||||
!showModerationChecklist,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown:
|
||||
auth.user &&
|
||||
tags.staffRoles.includes(auth.user.role) &&
|
||||
!showModerationChecklist,
|
||||
},
|
||||
{
|
||||
id: 'tech-review',
|
||||
link: `/moderation/technical-review/${project.id}`,
|
||||
@@ -764,6 +433,16 @@
|
||||
hoverOnly: true,
|
||||
shown: auth.user && tags.staffRoles.includes(auth.user.role),
|
||||
},
|
||||
{
|
||||
id: 'moderation-modpack-rescan',
|
||||
action: () => scanModal.show(),
|
||||
color: 'orange',
|
||||
hoverOnly: true,
|
||||
shown:
|
||||
auth.user &&
|
||||
tags.staffRoles.includes(auth.user.role) &&
|
||||
project.actualProjectType === 'modpack',
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: auth.user && tags.staffRoles.includes(auth.user.role),
|
||||
@@ -795,6 +474,9 @@
|
||||
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProject) }}
|
||||
</template>
|
||||
<template #tech-review> <ScanEyeIcon aria-hidden="true" /> Tech review </template>
|
||||
<template #moderation-modpack-rescan>
|
||||
<FolderSearchIcon aria-hidden="true" /> Rescan modpack
|
||||
</template>
|
||||
<template #report>
|
||||
<ReportIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.reportButton) }}
|
||||
@@ -1048,27 +730,22 @@ import {
|
||||
BookTextIcon,
|
||||
CalendarIcon,
|
||||
ChartIcon,
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
GameIcon,
|
||||
FolderSearchIcon,
|
||||
HeartIcon,
|
||||
InfoIcon,
|
||||
ListIcon,
|
||||
ModrinthIcon,
|
||||
MoreVerticalIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
ReportIcon,
|
||||
ScaleIcon,
|
||||
ScanEyeIcon,
|
||||
SearchIcon,
|
||||
ServerPlusIcon,
|
||||
SettingsIcon,
|
||||
VersionIcon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
@@ -1078,7 +755,6 @@ import {
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
getTagMessage,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
@@ -1098,8 +774,6 @@ import {
|
||||
ProjectSidebarServerInfo,
|
||||
ProjectSidebarTags,
|
||||
provideProjectPageContext,
|
||||
ScrollablePanel,
|
||||
ServersPromo,
|
||||
StyledInput,
|
||||
TagItem,
|
||||
useDebugLogger,
|
||||
@@ -1108,22 +782,20 @@ import {
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import VersionSummary from '@modrinth/ui/src/components/version/VersionSummary.vue'
|
||||
import { capitalizeString, formatProjectType, renderString } from '@modrinth/utils'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { nextTick, onScopeDispose, readonly, ref, useTemplateRef, watch, watchEffect } from 'vue'
|
||||
import { onScopeDispose, readonly, ref, useTemplateRef, watch, watchEffect } from 'vue'
|
||||
|
||||
import { navigateTo } from '#app'
|
||||
import Accordion from '~/components/ui/Accordion.vue'
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import AutomaticAccordion from '~/components/ui/AutomaticAccordion.vue'
|
||||
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
|
||||
import MessageBanner from '~/components/ui/MessageBanner.vue'
|
||||
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
|
||||
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
|
||||
import ModpackScanModal from '~/components/ui/moderation/ModpackScanModal.vue'
|
||||
import ProjectDownloadModal from '~/components/ui/ProjectDownloadModal/index.vue'
|
||||
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
|
||||
import { getSignInRouteObj } from '~/composables/auth.ts'
|
||||
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
@@ -1190,14 +862,7 @@ const debug = useDebugLogger('DownloadModal')
|
||||
const downloadModal = ref()
|
||||
const openInAppModal = ref()
|
||||
const overTheTopDownloadAnimation = ref()
|
||||
|
||||
const userSelectedGameVersion = ref(null)
|
||||
const userSelectedPlatform = ref(null)
|
||||
const showAllVersions = ref(false)
|
||||
|
||||
const gameVersionFilterInput = ref()
|
||||
|
||||
const versionFilter = ref('')
|
||||
const scanModal = ref()
|
||||
|
||||
const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.value != null)
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
@@ -1206,80 +871,6 @@ const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
|
||||
|
||||
const baseId = useId()
|
||||
|
||||
const currentGameVersion = computed(() => {
|
||||
if (!project.value) return null
|
||||
return (
|
||||
userSelectedGameVersion.value ||
|
||||
(project.value.game_versions.length === 1 && project.value.game_versions[0])
|
||||
)
|
||||
})
|
||||
|
||||
const possibleGameVersions = computed(() => {
|
||||
return versions.value
|
||||
.filter((x) => !currentPlatform.value || x.loaders.includes(currentPlatform.value))
|
||||
.flatMap((x) => x.game_versions)
|
||||
})
|
||||
|
||||
const possiblePlatforms = computed(() => {
|
||||
return versions.value
|
||||
.filter((x) => !currentGameVersion.value || x.game_versions.includes(currentGameVersion.value))
|
||||
.flatMap((x) => x.loaders)
|
||||
})
|
||||
|
||||
const currentPlatform = computed(() => {
|
||||
if (!project.value) return null
|
||||
return (
|
||||
userSelectedPlatform.value || (project.value.loaders.length === 1 && project.value.loaders[0])
|
||||
)
|
||||
})
|
||||
|
||||
const currentPlatformText = computed(() => {
|
||||
if (!currentPlatform.value) return null
|
||||
return formatMessage(getTagMessage(currentPlatform.value, 'loader'))
|
||||
})
|
||||
|
||||
function decorateModalDownloadUrl(url) {
|
||||
return createProjectDownloadUrl(url, {
|
||||
reason: downloadReason.value,
|
||||
gameVersion: currentGameVersion.value ?? undefined,
|
||||
loader: currentPlatform.value ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const releaseVersions = computed(() => {
|
||||
const set = new Set()
|
||||
for (const gv of tags.value.gameVersions || []) {
|
||||
if (gv?.version && gv.version_type === 'release') set.add(gv.version)
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
const nonReleaseVersions = computed(() => {
|
||||
const set = new Set()
|
||||
for (const gv of tags.value.gameVersions || []) {
|
||||
if (gv?.version && gv.version_type !== 'release') set.add(gv.version)
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
function isReleaseGameVersion(ver) {
|
||||
if (releaseVersions.value.has(ver)) return true
|
||||
if (nonReleaseVersions.value.has(ver)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const showVersionsCheckbox = computed(() => {
|
||||
const list = project.value?.game_versions || []
|
||||
let hasRelease = false
|
||||
let hasNonRelease = false
|
||||
for (const v of list) {
|
||||
if (isReleaseGameVersion(v)) hasRelease = true
|
||||
else hasNonRelease = true
|
||||
if (hasRelease && hasNonRelease) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const serverProject = computed(() => ({
|
||||
name: project.value.title,
|
||||
slug: project.value.slug || project.value.id,
|
||||
@@ -1295,16 +886,6 @@ function handlePlayServerProject() {
|
||||
})
|
||||
}
|
||||
|
||||
function installWithApp() {
|
||||
setTimeout(() => {
|
||||
getModrinthAppAccordion.value.open()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const gameVersionAccordion = ref()
|
||||
const platformAccordion = ref()
|
||||
const getModrinthAppAccordion = ref()
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const detailsMessages = defineMessages({
|
||||
@@ -1360,18 +941,10 @@ const messages = defineMessages({
|
||||
id: 'project.description.title',
|
||||
defaultMessage: 'Description',
|
||||
},
|
||||
dontHaveModrinthApp: {
|
||||
id: 'project.download.no-app',
|
||||
defaultMessage: "Don't have Modrinth App?",
|
||||
},
|
||||
dontShowAgain: {
|
||||
id: 'project.actions.dont-show-again',
|
||||
defaultMessage: "Don't show again",
|
||||
},
|
||||
downloadTitle: {
|
||||
id: 'project.download.title',
|
||||
defaultMessage: 'Download {title}',
|
||||
},
|
||||
downloadsStat: {
|
||||
id: 'project.stats.downloads-label',
|
||||
defaultMessage: '{count, plural, one {download} other {downloads}}',
|
||||
@@ -1410,26 +983,6 @@ const messages = defineMessages({
|
||||
id: 'project.gallery.title',
|
||||
defaultMessage: 'Gallery',
|
||||
},
|
||||
gameVersionError: {
|
||||
id: 'project.download.game-version-error',
|
||||
defaultMessage: 'Error: no game versions found',
|
||||
},
|
||||
gameVersionLabel: {
|
||||
id: 'project.download.game-version',
|
||||
defaultMessage: 'Game version: {version}',
|
||||
},
|
||||
gameVersionTooltip: {
|
||||
id: 'project.download.game-version-tooltip',
|
||||
defaultMessage: '{title} is only available for {version}',
|
||||
},
|
||||
gameVersionUnsupportedTooltip: {
|
||||
id: 'project.download.game-version-unsupported-tooltip',
|
||||
defaultMessage: '{title} does not support {gameVersion} for {platform}',
|
||||
},
|
||||
installWithModrinthApp: {
|
||||
id: 'project.download.install-with-app',
|
||||
defaultMessage: 'Install with Modrinth App',
|
||||
},
|
||||
licenseErrorMessage: {
|
||||
id: 'project.license.error',
|
||||
defaultMessage: 'License text could not be retrieved.',
|
||||
@@ -1454,30 +1007,10 @@ const messages = defineMessages({
|
||||
id: 'project.collections.none-found',
|
||||
defaultMessage: 'No collections found.',
|
||||
},
|
||||
noVersionsAvailable: {
|
||||
id: 'project.download.no-versions-available',
|
||||
defaultMessage: 'No versions available for {gameVersion} and {platform}.',
|
||||
},
|
||||
pageNotFound: {
|
||||
id: 'project.error.page-not-found',
|
||||
defaultMessage: 'The page could not be found',
|
||||
},
|
||||
platformError: {
|
||||
id: 'project.download.platform-error',
|
||||
defaultMessage: 'Error: no platforms found',
|
||||
},
|
||||
platformLabel: {
|
||||
id: 'project.download.platform',
|
||||
defaultMessage: 'Platform: {platform}',
|
||||
},
|
||||
platformTooltip: {
|
||||
id: 'project.download.platform-tooltip',
|
||||
defaultMessage: '{title} is only available for {platform}',
|
||||
},
|
||||
platformUnsupportedTooltip: {
|
||||
id: 'project.download.platform-unsupported-tooltip',
|
||||
defaultMessage: '{title} does not support {platform} for {gameVersion}',
|
||||
},
|
||||
projectIconUpdated: {
|
||||
id: 'project.notification.icon-updated.title',
|
||||
defaultMessage: 'Project icon updated',
|
||||
@@ -1506,22 +1039,6 @@ const messages = defineMessages({
|
||||
id: 'project.actions.review-project',
|
||||
defaultMessage: 'Review project',
|
||||
},
|
||||
searchGameVersions: {
|
||||
id: 'project.download.search-game-versions',
|
||||
defaultMessage: 'Search game versions...',
|
||||
},
|
||||
searchGameVersionsLabel: {
|
||||
id: 'project.download.search-game-versions-label',
|
||||
defaultMessage: 'Search game versions...',
|
||||
},
|
||||
selectGameVersion: {
|
||||
id: 'project.download.select-game-version',
|
||||
defaultMessage: 'Select game version',
|
||||
},
|
||||
selectPlatform: {
|
||||
id: 'project.download.select-platform',
|
||||
defaultMessage: 'Select platform',
|
||||
},
|
||||
serversPromoDescription: {
|
||||
id: 'project.actions.servers-promo.description',
|
||||
defaultMessage: 'Modrinth Hosting is the easiest way to play with your friends without hassle!',
|
||||
@@ -1538,10 +1055,6 @@ const messages = defineMessages({
|
||||
id: 'project.settings.title',
|
||||
defaultMessage: 'Settings',
|
||||
},
|
||||
showAllVersions: {
|
||||
id: 'project.download.show-all-versions',
|
||||
defaultMessage: 'Show all versions',
|
||||
},
|
||||
versionsTab: {
|
||||
id: 'project.versions.title',
|
||||
defaultMessage: 'Versions',
|
||||
@@ -1591,48 +1104,6 @@ async function getLicenseData(event) {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredVersions = computed(() => {
|
||||
const result = versions.value.filter(
|
||||
(x) =>
|
||||
x.game_versions?.includes(currentGameVersion.value) &&
|
||||
(x.loaders?.includes(currentPlatform.value) || project.value.project_type === 'resourcepack'),
|
||||
)
|
||||
debug('filteredVersions', {
|
||||
total: versions.value.length,
|
||||
filtered: result.length,
|
||||
currentGameVersion: currentGameVersion.value,
|
||||
currentPlatform: currentPlatform.value,
|
||||
versionsEnabled: versionsEnabled.value,
|
||||
versionsLoading: versionsV3Loading.value,
|
||||
sampleLoaders: versions.value.slice(0, 3).map((v) => v.loaders),
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const filteredRelease = computed(() => {
|
||||
return filteredVersions.value.find((x) => x.version_type === 'release')
|
||||
})
|
||||
|
||||
const filteredBeta = computed(() => {
|
||||
return filteredVersions.value.find(
|
||||
(x) =>
|
||||
x.version_type === 'beta' &&
|
||||
(!filteredRelease.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))),
|
||||
)
|
||||
})
|
||||
|
||||
const filteredAlpha = computed(() => {
|
||||
return filteredVersions.value.find(
|
||||
(x) =>
|
||||
x.version_type === 'alpha' &&
|
||||
(!filteredRelease.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))) &&
|
||||
(!filteredBeta.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredBeta.value.date_published))),
|
||||
)
|
||||
})
|
||||
|
||||
const displayCollectionsSearch = ref('')
|
||||
const collections = computed(() =>
|
||||
user.value && user.value.collections
|
||||
@@ -2407,40 +1878,6 @@ if (!route.name.startsWith('type-project-settings')) {
|
||||
|
||||
const onUserCollectProject = useClientTry(userCollectProject)
|
||||
|
||||
const { version, loader } = route.query
|
||||
|
||||
if (
|
||||
project.value &&
|
||||
project.value.game_versions.length > 0 &&
|
||||
project.value.game_versions.every((v) => !isReleaseGameVersion(v))
|
||||
) {
|
||||
showAllVersions.value = true
|
||||
}
|
||||
|
||||
if (project.value && version !== undefined && project.value.game_versions.includes(version)) {
|
||||
userSelectedGameVersion.value = version
|
||||
}
|
||||
|
||||
if (project.value && loader !== undefined && project.value.loaders.includes(loader)) {
|
||||
userSelectedPlatform.value = loader
|
||||
}
|
||||
|
||||
if (route.hash === '#download' || version !== undefined || loader !== undefined) {
|
||||
debug('eager loadVersions from setup', { hash: route.hash, version, loader })
|
||||
loadVersions()
|
||||
}
|
||||
|
||||
watch(downloadModal, (modal) => {
|
||||
if (!modal) return
|
||||
|
||||
// route.hash returns everything in the hash string, including the # itself
|
||||
if (route.hash === '#download') {
|
||||
debug('hash #download watch fired, opening modal')
|
||||
loadVersions()
|
||||
modal.show()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[versionsV3, _versionsV3Error],
|
||||
([data, error]) => {
|
||||
@@ -2661,32 +2098,11 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function closeDownloadModal(event) {
|
||||
downloadModal.value.hide(event)
|
||||
userSelectedPlatform.value = null
|
||||
userSelectedGameVersion.value = null
|
||||
showAllVersions.value = false
|
||||
}
|
||||
|
||||
function triggerDownloadAnimation() {
|
||||
overTheTopDownloadAnimation.value = true
|
||||
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
|
||||
}
|
||||
|
||||
function onDownload(event) {
|
||||
triggerDownloadAnimation()
|
||||
setTimeout(() => {
|
||||
closeDownloadModal(event)
|
||||
}, 400)
|
||||
}
|
||||
|
||||
function onVersionNavigate(url) {
|
||||
closeDownloadModal()
|
||||
nextTick(() => {
|
||||
navigateTo(url)
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteVersion(id) {
|
||||
if (!id) return
|
||||
|
||||
@@ -2842,11 +2258,6 @@ provideProjectPageContext({
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.accordion-with-bg) {
|
||||
@apply rounded-2xl bg-bg p-2;
|
||||
--scrollable-pane-bg: var(--color-bg);
|
||||
}
|
||||
|
||||
.over-the-top-download-animation {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
@@ -2897,12 +2308,6 @@ provideProjectPageContext({
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) and (max-width: 767px) {
|
||||
.modrinth-app-section {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.servers-popup {
|
||||
box-shadow:
|
||||
0 0 12px 1px rgba(0, 175, 92, 0.6),
|
||||
|
||||
@@ -80,6 +80,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<ProjectDownloadModal
|
||||
ref="dependencyDownloadModal"
|
||||
download-reason="dependency"
|
||||
:use-route-hash="false"
|
||||
:update-route-selection="false"
|
||||
@download="emit('onDownload')"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<nuxt-link
|
||||
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
|
||||
@@ -311,11 +318,25 @@
|
||||
<template #dependencyActions="{ dependency }">
|
||||
<ButtonStyled circular>
|
||||
<nuxt-link
|
||||
v-if="createDependencyLink(dependency)"
|
||||
v-if="
|
||||
createDependencyLink({
|
||||
project: dependency.project,
|
||||
version: dependency.version ?? getDependencyVersion(dependency.dependency),
|
||||
})
|
||||
"
|
||||
v-tooltip="
|
||||
formatMessage(dependency.version ? messages.viewVersion : messages.viewProject)
|
||||
formatMessage(
|
||||
(dependency.version ?? getDependencyVersion(dependency.dependency))
|
||||
? messages.viewVersion
|
||||
: messages.viewProject,
|
||||
)
|
||||
"
|
||||
:to="
|
||||
createDependencyLink({
|
||||
project: dependency.project,
|
||||
version: dependency.version ?? getDependencyVersion(dependency.dependency),
|
||||
})
|
||||
"
|
||||
:to="createDependencyLink(dependency)"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalIcon />
|
||||
@@ -324,37 +345,40 @@
|
||||
<ButtonStyled circular color="brand" color-fill="text">
|
||||
<a
|
||||
v-if="
|
||||
dependency.version && dependency.dependency.dependency_type !== 'incompatible'
|
||||
(dependency.version ?? getDependencyVersion(dependency.dependency)) &&
|
||||
dependency.dependency.dependency_type !== 'incompatible'
|
||||
"
|
||||
v-tooltip="
|
||||
dependencyVersionPrimaryFiles[dependency.version.id]
|
||||
? dependencyVersionPrimaryFiles[dependency.version.id].filename +
|
||||
' (' +
|
||||
formatBytes(dependencyVersionPrimaryFiles[dependency.version.id].size) +
|
||||
')'
|
||||
: formatMessage(messages.noPrimaryFile)
|
||||
"
|
||||
:href="
|
||||
createProjectDownloadUrl(
|
||||
dependencyVersionPrimaryFiles[dependency.version.id].url,
|
||||
{
|
||||
reason: 'dependency',
|
||||
},
|
||||
getDependencyPrimaryFileTooltip(
|
||||
dependency.version ?? getDependencyVersion(dependency.dependency),
|
||||
)
|
||||
"
|
||||
:download="dependencyVersionPrimaryFiles[dependency.version.id].filename"
|
||||
:disabled="dependencyVersionPrimaryFiles[dependency.version.id].url === undefined"
|
||||
:href="
|
||||
getDependencyDownloadUrl(
|
||||
dependency.version ?? getDependencyVersion(dependency.dependency),
|
||||
)
|
||||
"
|
||||
:download="
|
||||
getDependencyPrimaryFile(
|
||||
dependency.version ?? getDependencyVersion(dependency.dependency),
|
||||
)?.filename
|
||||
"
|
||||
:disabled="
|
||||
!getDependencyPrimaryFile(
|
||||
dependency.version ?? getDependencyVersion(dependency.dependency),
|
||||
)?.url
|
||||
"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
<a
|
||||
<button
|
||||
v-else-if="dependency.project"
|
||||
v-tooltip="formatMessage(messages.downloadProject)"
|
||||
:href="`/project/${dependency.project.id}#download`"
|
||||
target="_blank"
|
||||
:aria-label="formatMessage(messages.downloadProject)"
|
||||
@click="openDependencyDownloadModal(dependency.project, $event)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</VersionPage>
|
||||
@@ -502,6 +526,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { onServerPrefetch } from 'vue'
|
||||
|
||||
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
|
||||
import ProjectDownloadModal from '~/components/ui/ProjectDownloadModal/index.vue'
|
||||
import { getSignInRouteObj } from '~/composables/auth.ts'
|
||||
import { projectQueryOptions, STALE_TIME } from '~/composables/queries/project'
|
||||
import { versionQueryOptions } from '~/composables/queries/version'
|
||||
@@ -658,6 +683,80 @@ watch(
|
||||
|
||||
const enrichment = computed(() => contextDependencies.value ?? undefined)
|
||||
|
||||
const projectOnlyDependencyProjectIds = computed(() => {
|
||||
const projectIds = new Set<string>()
|
||||
for (const dependency of version.value?.dependencies ?? []) {
|
||||
if (
|
||||
dependency.project_id &&
|
||||
!dependency.version_id &&
|
||||
dependency.dependency_type !== 'incompatible'
|
||||
) {
|
||||
projectIds.add(dependency.project_id)
|
||||
}
|
||||
}
|
||||
return [...projectIds]
|
||||
})
|
||||
|
||||
const dependencyResolutionLoaders = computed(() => {
|
||||
if (!version.value) return []
|
||||
if (version.value.loaders.includes('mrpack')) {
|
||||
return (version.value.mrpack_loaders ?? []).filter((loader) => loader !== 'minecraft')
|
||||
}
|
||||
return version.value.loaders
|
||||
})
|
||||
|
||||
const { data: projectOnlyDependencyVersions } = useQuery({
|
||||
queryKey: computed(
|
||||
() =>
|
||||
[
|
||||
'version-page',
|
||||
'project-only-dependency-versions',
|
||||
version.value?.id,
|
||||
version.value?.game_versions ?? [],
|
||||
dependencyResolutionLoaders.value,
|
||||
projectOnlyDependencyProjectIds.value,
|
||||
] as const,
|
||||
),
|
||||
queryFn: async () => {
|
||||
const currentVersion = version.value!
|
||||
const entries = await Promise.all(
|
||||
projectOnlyDependencyProjectIds.value.map(async (projectId) => {
|
||||
let versions: Labrinth.Versions.v2.Version[] = []
|
||||
try {
|
||||
versions = await client.labrinth.versions_v2.getProjectVersions(projectId, {
|
||||
game_versions: currentVersion.game_versions,
|
||||
loaders: dependencyResolutionLoaders.value,
|
||||
include_changelog: false,
|
||||
limit: 100,
|
||||
})
|
||||
} catch {
|
||||
return [projectId, undefined] as const
|
||||
}
|
||||
|
||||
return [
|
||||
projectId,
|
||||
getOnlyCompatibleDependencyVersion(
|
||||
versions,
|
||||
currentVersion.game_versions,
|
||||
dependencyResolutionLoaders.value,
|
||||
),
|
||||
] as const
|
||||
}),
|
||||
)
|
||||
|
||||
const versionsByProjectId: Record<string, Labrinth.Versions.v2.Version> = {}
|
||||
for (const [projectId, dependencyVersion] of entries) {
|
||||
if (dependencyVersion) {
|
||||
versionsByProjectId[projectId] = dependencyVersion
|
||||
}
|
||||
}
|
||||
|
||||
return versionsByProjectId
|
||||
},
|
||||
enabled: computed(() => !!version.value && projectOnlyDependencyProjectIds.value.length > 0),
|
||||
staleTime: STALE_TIME,
|
||||
})
|
||||
|
||||
const primaryFile = computed(
|
||||
() => version.value?.files?.find((file) => file.primary) ?? version.value?.files?.[0],
|
||||
)
|
||||
@@ -690,6 +789,7 @@ useSeoMeta({
|
||||
const editModal = useTemplateRef('editModal')
|
||||
const confirmModal = useTemplateRef('confirmModal')
|
||||
const packageModal = useTemplateRef('packageModal')
|
||||
const dependencyDownloadModal = useTemplateRef('dependencyDownloadModal')
|
||||
|
||||
const packageLoaders = ref(['forge', 'fabric', 'quilt', 'neoforge'])
|
||||
const packageLoaderOptions = [
|
||||
@@ -712,6 +812,22 @@ function handleOpenEditVersionModal(versionId: string, projectId: string, stageI
|
||||
editModal.value?.openEditVersionModal(versionId, projectId, stageId)
|
||||
}
|
||||
|
||||
function openDependencyDownloadModal(
|
||||
dependencyProject: Labrinth.Projects.v2.Project,
|
||||
event: MouseEvent,
|
||||
) {
|
||||
const baseGameVersions = new Set(version.value?.game_versions ?? [])
|
||||
const baseLoaders = new Set(dependencyResolutionLoaders.value)
|
||||
|
||||
dependencyDownloadModal.value?.show(event, {
|
||||
projectId: dependencyProject.id,
|
||||
incompatibleGameVersions: dependencyProject.game_versions.filter(
|
||||
(gameVersion) => !baseGameVersions.has(gameVersion),
|
||||
),
|
||||
incompatibleLoaders: dependencyProject.loaders.filter((loader) => !baseLoaders.has(loader)),
|
||||
})
|
||||
}
|
||||
|
||||
const deleteVersionMutation = useMutation({
|
||||
mutationFn: () => client.labrinth.versions_v3.deleteVersion(version.value!.id),
|
||||
onSuccess: async () => {
|
||||
@@ -823,6 +939,10 @@ const messages = defineMessages({
|
||||
id: 'version.download.no-primary-file',
|
||||
defaultMessage: 'Error: No primary file found',
|
||||
},
|
||||
downloadVersion: {
|
||||
id: 'version.download.version',
|
||||
defaultMessage: 'Download {version} ({size})',
|
||||
},
|
||||
edit: {
|
||||
id: 'version.edit.button',
|
||||
defaultMessage: 'Edit',
|
||||
@@ -873,7 +993,7 @@ const messages = defineMessages({
|
||||
},
|
||||
downloadProject: {
|
||||
id: 'version.download.download-dependency',
|
||||
defaultMessage: 'Download dependency',
|
||||
defaultMessage: 'Select a version to download',
|
||||
},
|
||||
requiredResourcePack: {
|
||||
id: 'version.download.required-resource-pack',
|
||||
@@ -951,13 +1071,20 @@ const copyFileHash = async (
|
||||
file: Labrinth.Versions.v3.VersionFile,
|
||||
method: Labrinth.Versions.v3.FileHashType,
|
||||
) => {
|
||||
await navigator.clipboard.writeText(file.hashes[method])
|
||||
await copyToClipboard(file.hashes[method])
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
const dependencyVersionPrimaryFiles = computed(() => {
|
||||
const versions = enrichment.value?.versions
|
||||
const versions = [
|
||||
...(enrichment.value?.versions ?? []),
|
||||
...Object.values(projectOnlyDependencyVersions.value ?? {}),
|
||||
]
|
||||
const primaryFileMap: Record<string, Labrinth.Versions.v2.VersionFile> = {}
|
||||
versions?.forEach((depVersion) => {
|
||||
versions.forEach((depVersion) => {
|
||||
const depPrimaryFile = depVersion.files.find((file) => file.primary) ?? depVersion.files[0]
|
||||
|
||||
primaryFileMap[depVersion.id] = depPrimaryFile
|
||||
@@ -965,6 +1092,65 @@ const dependencyVersionPrimaryFiles = computed(() => {
|
||||
return primaryFileMap
|
||||
})
|
||||
|
||||
function getDependencyVersion(dependency: Labrinth.Versions.v3.Dependency) {
|
||||
if (dependency.version_id || !dependency.project_id) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return projectOnlyDependencyVersions.value?.[dependency.project_id]
|
||||
}
|
||||
|
||||
function getOnlyCompatibleDependencyVersion(
|
||||
versions: Labrinth.Versions.v2.Version[],
|
||||
gameVersions: string[],
|
||||
loaders: string[],
|
||||
) {
|
||||
const compatibleVersions = versions
|
||||
.filter(
|
||||
(version) =>
|
||||
version.game_versions.some((gameVersion) => gameVersions.includes(gameVersion)) &&
|
||||
version.loaders.some((loader) => loaders.includes(loader)),
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime())
|
||||
const targetKeys = new Set(
|
||||
compatibleVersions.flatMap((version) =>
|
||||
version.game_versions.flatMap((gameVersion) =>
|
||||
gameVersions.includes(gameVersion)
|
||||
? version.loaders
|
||||
.filter((loader) => loaders.includes(loader))
|
||||
.map((loader) => `${gameVersion}:${loader}`)
|
||||
: [],
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return targetKeys.size === 1 ? compatibleVersions[0] : undefined
|
||||
}
|
||||
|
||||
function getDependencyPrimaryFile(version?: Labrinth.Versions.v2.Version) {
|
||||
return version ? dependencyVersionPrimaryFiles.value[version.id] : undefined
|
||||
}
|
||||
|
||||
function getDependencyPrimaryFileTooltip(version?: Labrinth.Versions.v2.Version) {
|
||||
const dependencyPrimaryFile = getDependencyPrimaryFile(version)
|
||||
return dependencyPrimaryFile
|
||||
? formatMessage(messages.downloadVersion, {
|
||||
version: version?.name || version?.version_number || dependencyPrimaryFile.filename,
|
||||
size: formatBytes(dependencyPrimaryFile.size),
|
||||
})
|
||||
: formatMessage(messages.noPrimaryFile)
|
||||
}
|
||||
|
||||
function getDependencyDownloadUrl(version?: Labrinth.Versions.v2.Version) {
|
||||
const dependencyPrimaryFile = getDependencyPrimaryFile(version)
|
||||
return dependencyPrimaryFile?.url
|
||||
? createProjectDownloadUrl(dependencyPrimaryFile.url, {
|
||||
reason: 'dependency',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
|
||||
function createDependencyLink(context: {
|
||||
project?: Labrinth.Projects.v2.Project
|
||||
version?: Labrinth.Versions.v2.Version
|
||||
|
||||
@@ -158,6 +158,9 @@ const {
|
||||
serverContentData,
|
||||
serverFilters,
|
||||
serverHideInstalled,
|
||||
serverContentServerOnly,
|
||||
showServerOnlyToggle,
|
||||
serverEnvironmentOverride,
|
||||
hideSelectedServerInstalls,
|
||||
installingProjectIds,
|
||||
optimisticallyInstalledProjectIds,
|
||||
@@ -378,10 +381,12 @@ const searchState = useBrowseSearch({
|
||||
projectType: projectTypeId,
|
||||
tags,
|
||||
providedFilters: serverFilters,
|
||||
environmentOverride: serverEnvironmentOverride,
|
||||
search,
|
||||
persistentQueryParams: ['sid', 'wid', 'shi', 'from'],
|
||||
persistentQueryParams: ['sid', 'wid', 'shi', 'so', 'from'],
|
||||
getExtraQueryParams: () => ({
|
||||
shi: serverHideInstalled.value ? 'true' : undefined,
|
||||
so: showServerOnlyToggle.value && serverContentServerOnly.value ? 'true' : undefined,
|
||||
}),
|
||||
maxResultsOptions: currentMaxResultsOptions,
|
||||
displayMode: resultsDisplayMode,
|
||||
@@ -469,6 +474,10 @@ provideBrowseManager({
|
||||
queuedServerInstallCount.value > 0,
|
||||
),
|
||||
hideSelectedLabel: computed(() => formatMessage(commonMessages.hideSelectedContentLabel)),
|
||||
serverOnly: serverContentServerOnly,
|
||||
showServerOnly: showServerOnlyToggle,
|
||||
serverOnlyLabel: computed(() => formatMessage(commonMessages.serverOnlyLabel)),
|
||||
hiddenFilterTypes: computed(() => (showServerOnlyToggle.value ? ['environment'] : [])),
|
||||
displayMode: resultsDisplayMode,
|
||||
cycleDisplayMode: cycleSearchDisplayMode,
|
||||
maxResultsOptions: currentMaxResultsOptions,
|
||||
|
||||
@@ -313,6 +313,12 @@ export function createManageVersionContext(
|
||||
// detect primary file if no primary file is set
|
||||
const primaryFileIndex = primaryFile.value ? null : detectPrimaryFileIndex(newFiles)
|
||||
|
||||
if (hasSupplementaryMrpackFile(newFiles, primaryFileIndex)) {
|
||||
notifyInvalidSupplementaryMrpack()
|
||||
handlingNewFiles.value = false
|
||||
return
|
||||
}
|
||||
|
||||
newFiles.forEach((file) => filesToAdd.value.push({ file }))
|
||||
|
||||
if (primaryFileIndex !== null) {
|
||||
@@ -567,6 +573,29 @@ export function createManageVersionContext(
|
||||
})
|
||||
|
||||
// File handling helpers
|
||||
function isMrpackFile(file: File): boolean {
|
||||
const name = file.name.toLowerCase()
|
||||
return name.endsWith('.mrpack') || name.endsWith('.mrpack-primary')
|
||||
}
|
||||
|
||||
function hasSupplementaryMrpackFile(
|
||||
files: File[] | Labrinth.Versions.v3.DraftVersionFile[],
|
||||
primaryFileIndex: number | null = 0,
|
||||
): boolean {
|
||||
return files.some((file, index) => {
|
||||
const rawFile = 'file' in file ? file.file : file
|
||||
return index !== primaryFileIndex && isMrpackFile(rawFile)
|
||||
})
|
||||
}
|
||||
|
||||
function notifyInvalidSupplementaryMrpack() {
|
||||
addNotification({
|
||||
title: 'Invalid supplementary file',
|
||||
text: 'mrpacks cannot be uploaded as supplementary files, only as the primary file',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
function detectPrimaryFileIndex(files: File[]): number {
|
||||
const extensionPriority = ['.jar', '.zip', '.litemod', '.mrpack', '.mrpack-primary']
|
||||
|
||||
@@ -738,6 +767,12 @@ export function createManageVersionContext(
|
||||
async function handleCreateVersion() {
|
||||
const version = toRaw(draftVersion.value)
|
||||
const files = toRaw(filesToAdd.value)
|
||||
|
||||
if (hasSupplementaryMrpackFile(files)) {
|
||||
notifyInvalidSupplementaryMrpack()
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
isUploading.value = true
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
-11
@@ -343,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()
|
||||
@@ -379,16 +387,17 @@ pub fn app_config(
|
||||
.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")]
|
||||
@@ -400,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
@@ -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",
|
||||
))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -76,6 +78,7 @@ pub enum LegacyNotificationBody {
|
||||
SharedInstanceInvite {
|
||||
shared_instance_id: String,
|
||||
shared_instance_name: String,
|
||||
invited_by: UserId,
|
||||
},
|
||||
StatusChange {
|
||||
project_id: ProjectId,
|
||||
@@ -304,9 +307,11 @@ impl LegacyNotification {
|
||||
NotificationBody::SharedInstanceInvite {
|
||||
shared_instance_id,
|
||||
shared_instance_name,
|
||||
invited_by,
|
||||
} => LegacyNotificationBody::SharedInstanceInvite {
|
||||
shared_instance_id,
|
||||
shared_instance_name,
|
||||
invited_by,
|
||||
},
|
||||
NotificationBody::StatusChange {
|
||||
project_id,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -179,6 +179,7 @@ pub enum NotificationBody {
|
||||
SharedInstanceInvite {
|
||||
shared_instance_id: String,
|
||||
shared_instance_name: String,
|
||||
invited_by: UserId,
|
||||
},
|
||||
StatusChange {
|
||||
project_id: ProjectId,
|
||||
@@ -706,7 +707,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,11 +5,19 @@ use eyre::{Context, eyre};
|
||||
use prometheus::{IntGauge, Registry};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(heap).service(flame_graph);
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Get a heap profile.
|
||||
#[utoipa::path(
|
||||
tag = "debug",
|
||||
responses((
|
||||
status = OK,
|
||||
body = Vec<u8>,
|
||||
content_type = "application/octet-stream"
|
||||
))
|
||||
)]
|
||||
#[get("/pprof/heap", guard = "admin_key_guard")]
|
||||
pub async fn heap() -> Result<HttpResponse, ApiError> {
|
||||
let mut prof_ctl = jemalloc_pprof::PROF_CTL.as_ref().unwrap().lock().await;
|
||||
@@ -23,7 +31,11 @@ pub async fn heap() -> Result<HttpResponse, ApiError> {
|
||||
.body(pprof))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Get a heap flame graph.
|
||||
#[utoipa::path(
|
||||
tag = "debug",
|
||||
responses((status = OK, body = String, content_type = "image/svg+xml"))
|
||||
)]
|
||||
#[get("/pprof/heap/flamegraph", guard = "admin_key_guard")]
|
||||
pub async fn flame_graph() -> Result<HttpResponse, ApiError> {
|
||||
let mut prof_ctl = jemalloc_pprof::PROF_CTL.as_ref().unwrap().lock().await;
|
||||
|
||||
@@ -23,9 +23,9 @@ use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tracing::trace;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(
|
||||
utoipa_actix_web::scope("/admin")
|
||||
web::scope("/admin")
|
||||
.service(count_download)
|
||||
.service(force_reindex)
|
||||
.service(force_reindex_project),
|
||||
@@ -130,7 +130,10 @@ async fn resolve_download_attribution_version(
|
||||
}
|
||||
|
||||
// This is an internal route, cannot be used without key
|
||||
/// Count a download.
|
||||
#[utoipa::path(
|
||||
context_path = "/admin",
|
||||
tag = "v2 admin",
|
||||
patch,
|
||||
operation_id = "countDownload",
|
||||
responses(
|
||||
@@ -308,7 +311,10 @@ pub async fn count_download(
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
/// Reindex all projects.
|
||||
#[utoipa::path(
|
||||
context_path = "/admin",
|
||||
tag = "v2 admin",
|
||||
post,
|
||||
operation_id = "forceReindex",
|
||||
responses(
|
||||
@@ -330,7 +336,10 @@ pub async fn force_reindex(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
/// Reindex a project.
|
||||
#[utoipa::path(
|
||||
context_path = "/admin",
|
||||
tag = "v2 admin",
|
||||
post,
|
||||
operation_id = "forceReindexProject",
|
||||
responses(
|
||||
|
||||
@@ -25,7 +25,7 @@ use url::Url;
|
||||
|
||||
use crate::routes::ApiError;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(ingest_click)
|
||||
.service(get_all)
|
||||
.service(create)
|
||||
@@ -40,9 +40,14 @@ pub struct IngestClick {
|
||||
pub affiliate_code_id: AffiliateCodeId,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Ingest an affiliate click.
|
||||
#[utoipa::path(
|
||||
context_path = "/affiliate",
|
||||
tag = "affiliates",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/ingest-click")]
|
||||
async fn ingest_click(
|
||||
pub async fn ingest_click(
|
||||
req: HttpRequest,
|
||||
web::Json(ingest_click): web::Json<IngestClick>,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -136,11 +141,14 @@ async fn ingest_click(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List affiliate codes.
|
||||
#[utoipa::path(
|
||||
context_path = "/affiliate",
|
||||
tag = "affiliates",
|
||||
responses((status = OK, body = inline(Vec<AffiliateCode>)))
|
||||
)]
|
||||
#[get("")]
|
||||
async fn get_all(
|
||||
pub async fn get_all(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -187,11 +195,14 @@ pub struct CreateRequest {
|
||||
pub source_name: String,
|
||||
}
|
||||
|
||||
/// Create an affiliate code.
|
||||
#[utoipa::path(
|
||||
context_path = "/affiliate",
|
||||
tag = "affiliates",
|
||||
responses((status = OK, body = inline(AffiliateCode)))
|
||||
)]
|
||||
#[put("")]
|
||||
async fn create(
|
||||
pub async fn create(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -263,11 +274,14 @@ async fn create(
|
||||
Ok(web::Json(AffiliateCode::from(code, is_admin)))
|
||||
}
|
||||
|
||||
/// Get an affiliate code.
|
||||
#[utoipa::path(
|
||||
context_path = "/affiliate",
|
||||
tag = "affiliates",
|
||||
responses((status = OK, body = inline(AffiliateCode)))
|
||||
)]
|
||||
#[get("/{id}")]
|
||||
async fn get(
|
||||
pub async fn get(
|
||||
req: HttpRequest,
|
||||
path: web::Path<(AffiliateCodeId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -302,9 +316,14 @@ async fn get(
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Delete an affiliate code.
|
||||
#[utoipa::path(
|
||||
context_path = "/affiliate",
|
||||
tag = "affiliates",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[delete("/{id}")]
|
||||
async fn delete(
|
||||
pub async fn delete(
|
||||
req: HttpRequest,
|
||||
path: web::Path<(AffiliateCodeId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -350,9 +369,14 @@ pub struct PatchRequest {
|
||||
pub source_name: String,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Update an affiliate code.
|
||||
#[utoipa::path(
|
||||
context_path = "/affiliate",
|
||||
tag = "affiliates",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[patch("/{id}")]
|
||||
async fn patch(
|
||||
pub async fn patch(
|
||||
req: HttpRequest,
|
||||
path: web::Path<(AffiliateCodeId,)>,
|
||||
pool: web::Data<PgPool>,
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::error::Context;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(list)
|
||||
.service(update_group)
|
||||
.service(scan)
|
||||
@@ -37,7 +37,7 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
.service(split);
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
struct AttributionGroupResponse {
|
||||
id: crate::models::ids::AttributionGroupId,
|
||||
flame_project: Option<FlameProject>,
|
||||
@@ -48,7 +48,7 @@ struct AttributionGroupResponse {
|
||||
versions: Vec<VersionInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[derive(Clone, Serialize, utoipa::ToSchema)]
|
||||
struct VersionInfo {
|
||||
id: VersionId,
|
||||
name: String,
|
||||
@@ -56,7 +56,7 @@ struct VersionInfo {
|
||||
date_created: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
struct AttributionFileResponse {
|
||||
name: String,
|
||||
sha1: String,
|
||||
@@ -67,7 +67,7 @@ struct AttributionFileResponse {
|
||||
moderation_external_license: Option<ModerationExternalLicenseResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[derive(Clone, Serialize, utoipa::ToSchema)]
|
||||
struct ModerationExternalLicenseResponse {
|
||||
id: i64,
|
||||
title: Option<String>,
|
||||
@@ -92,9 +92,14 @@ struct ScanResponse {
|
||||
queued_files: u64,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Queue an attribution scan.
|
||||
#[utoipa::path(
|
||||
context_path = "/attribution",
|
||||
tag = "attribution",
|
||||
responses((status = OK, body = ScanResponse))
|
||||
)]
|
||||
#[post("/scan")]
|
||||
async fn scan(
|
||||
pub async fn scan(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -278,9 +283,17 @@ async fn force_scan_file(
|
||||
Ok(web::Json(scan_summary))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// List project attribution groups.
|
||||
#[utoipa::path(
|
||||
context_path = "/attribution",
|
||||
tag = "attribution",
|
||||
params(
|
||||
("project_id" = ProjectId, Path)
|
||||
),
|
||||
responses((status = OK, body = inline(Vec<AttributionGroupResponse>)))
|
||||
)]
|
||||
#[get("/{project_id}")]
|
||||
async fn list(
|
||||
pub async fn list(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -528,9 +541,14 @@ struct UpdateGroupBody {
|
||||
attribution: AttributionResolution,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Update an attribution group.
|
||||
#[utoipa::path(
|
||||
context_path = "/attribution",
|
||||
tag = "attribution",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[patch("/group/{group_id}")]
|
||||
async fn update_group(
|
||||
pub async fn update_group(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -609,9 +627,14 @@ struct AssignBody {
|
||||
project_id: ProjectId,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Move a file to an attribution group.
|
||||
#[utoipa::path(
|
||||
context_path = "/attribution",
|
||||
tag = "attribution",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/assign")]
|
||||
async fn assign(
|
||||
pub async fn assign(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -765,9 +788,14 @@ struct SplitBody {
|
||||
project_id: ProjectId,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Split a file into a new attribution group.
|
||||
#[utoipa::path(
|
||||
context_path = "/attribution",
|
||||
tag = "attribution",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/split")]
|
||||
async fn split(
|
||||
pub async fn split(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
|
||||
@@ -42,7 +42,7 @@ use stripe::{
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/billing")
|
||||
.service(products)
|
||||
@@ -58,11 +58,18 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.service(active_servers)
|
||||
.service(initiate_payment)
|
||||
.service(stripe_webhook)
|
||||
.service(refund_charge),
|
||||
.service(refund_charge)
|
||||
.service(reprocess_charge_tax),
|
||||
);
|
||||
}
|
||||
|
||||
#[get("products")]
|
||||
/// List products.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
responses((status = OK, body = serde_json::Value))
|
||||
)]
|
||||
#[get("/products")]
|
||||
pub async fn products(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -99,7 +106,14 @@ struct SubscriptionsQuery {
|
||||
pub user_id: Option<ariadne::ids::UserId>,
|
||||
}
|
||||
|
||||
#[get("subscriptions")]
|
||||
/// List subscriptions.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
params(("user_id" = Option<ariadne::ids::UserId>, Query)),
|
||||
responses((status = OK, body = serde_json::Value))
|
||||
)]
|
||||
#[get("/subscriptions")]
|
||||
pub async fn subscriptions(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -141,7 +155,7 @@ pub async fn subscriptions(
|
||||
Ok(HttpResponse::Ok().json(subscriptions))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ChargeRefundAmount {
|
||||
Full,
|
||||
@@ -149,14 +163,21 @@ pub enum ChargeRefundAmount {
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct ChargeRefund {
|
||||
#[serde(flatten)]
|
||||
pub amount: ChargeRefundAmount,
|
||||
pub unprovision: Option<bool>,
|
||||
}
|
||||
|
||||
#[post("charge/{id}/refund")]
|
||||
/// Refund a charge.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
request_body = ChargeRefund,
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/charge/{id}/refund")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn refund_charge(
|
||||
req: HttpRequest,
|
||||
@@ -419,7 +440,13 @@ pub async fn refund_charge(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[post("charge/{id}/tax/reprocess")]
|
||||
/// Reprocess tax for a charge.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/charge/{id}/tax/reprocess")]
|
||||
pub async fn reprocess_charge_tax(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -587,8 +614,9 @@ pub async fn reprocess_charge_tax(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct SubscriptionEdit {
|
||||
#[schema(value_type = String)]
|
||||
pub interval: Option<PriceDuration>,
|
||||
pub payment_method: Option<String>,
|
||||
pub cancelled: Option<bool>,
|
||||
@@ -601,7 +629,17 @@ pub struct SubscriptionEditQuery {
|
||||
pub dry: Option<bool>,
|
||||
}
|
||||
|
||||
#[patch("subscription/{id}")]
|
||||
/// Update a subscription.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
params(("dry" = Option<bool>, Query)),
|
||||
responses(
|
||||
(status = OK, body = serde_json::Value),
|
||||
(status = NO_CONTENT),
|
||||
)
|
||||
)]
|
||||
#[patch("/subscription/{id}")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn edit_subscription(
|
||||
req: HttpRequest,
|
||||
@@ -1091,7 +1129,13 @@ pub async fn edit_subscription(
|
||||
}
|
||||
}
|
||||
|
||||
#[get("customer")]
|
||||
/// Get the current customer.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
responses((status = OK, body = serde_json::Value))
|
||||
)]
|
||||
#[get("/customer")]
|
||||
pub async fn user_customer(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -1129,7 +1173,14 @@ pub struct ChargesQuery {
|
||||
pub user_id: Option<ariadne::ids::UserId>,
|
||||
}
|
||||
|
||||
#[get("payments")]
|
||||
/// List payments.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
params(("user_id" = Option<ariadne::ids::UserId>, Query)),
|
||||
responses((status = OK, body = serde_json::Value))
|
||||
)]
|
||||
#[get("/payments")]
|
||||
pub async fn charges(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -1188,7 +1239,13 @@ pub async fn charges(
|
||||
))
|
||||
}
|
||||
|
||||
#[post("payment_method")]
|
||||
/// Start a payment method flow.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
responses((status = OK, body = serde_json::Value))
|
||||
)]
|
||||
#[post("/payment_method")]
|
||||
pub async fn add_payment_method_flow(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -1241,7 +1298,13 @@ pub struct EditPaymentMethod {
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
#[patch("payment_method/{id}")]
|
||||
/// Update a payment method.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[patch("/payment_method/{id}")]
|
||||
pub async fn edit_payment_method(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
@@ -1305,7 +1368,13 @@ pub async fn edit_payment_method(
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("payment_method/{id}")]
|
||||
/// Remove a payment method.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[delete("/payment_method/{id}")]
|
||||
pub async fn remove_payment_method(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
@@ -1388,7 +1457,16 @@ pub async fn remove_payment_method(
|
||||
}
|
||||
}
|
||||
|
||||
#[get("payment_methods")]
|
||||
/// List payment methods.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
responses(
|
||||
(status = OK, body = serde_json::Value),
|
||||
(status = NO_CONTENT),
|
||||
)
|
||||
)]
|
||||
#[get("/payment_methods")]
|
||||
pub async fn payment_methods(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -1432,7 +1510,24 @@ pub struct ActiveServersQuery {
|
||||
pub subscription_status: Option<SubscriptionStatus>,
|
||||
}
|
||||
|
||||
#[get("active_servers")]
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
struct ActiveServerResponse {
|
||||
pub user_id: ariadne::ids::UserId,
|
||||
pub server_id: String,
|
||||
pub price_id: crate::models::ids::ProductPriceId,
|
||||
#[schema(value_type = String)]
|
||||
pub interval: PriceDuration,
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
/// List active servers.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
params(("subscription_status" = Option<String>, Query)),
|
||||
responses((status = OK, body = inline(Vec<ActiveServerResponse>)))
|
||||
)]
|
||||
#[get("/active_servers")]
|
||||
pub async fn active_servers(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -1457,21 +1552,12 @@ pub async fn active_servers(
|
||||
)
|
||||
.await?;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ActiveServer {
|
||||
pub user_id: ariadne::ids::UserId,
|
||||
pub server_id: String,
|
||||
pub price_id: crate::models::ids::ProductPriceId,
|
||||
pub interval: PriceDuration,
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
let server_ids = servers
|
||||
.into_iter()
|
||||
.filter_map(|x| {
|
||||
x.metadata.as_ref().and_then(|metadata| match metadata {
|
||||
SubscriptionMetadata::Pyro { id, region } => {
|
||||
Some(ActiveServer {
|
||||
Some(ActiveServerResponse {
|
||||
user_id: x.user_id.into(),
|
||||
server_id: id.clone(),
|
||||
price_id: x.price_id.into(),
|
||||
@@ -1482,12 +1568,12 @@ pub async fn active_servers(
|
||||
SubscriptionMetadata::Medal { .. } => None,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<ActiveServer>>();
|
||||
.collect::<Vec<ActiveServerResponse>>();
|
||||
|
||||
Ok(HttpResponse::Ok().json(server_ids))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum PaymentRequestType {
|
||||
PaymentMethod { id: String },
|
||||
@@ -1507,7 +1593,7 @@ impl PaymentRequestType {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ChargeRequestType {
|
||||
Existing {
|
||||
@@ -1515,11 +1601,12 @@ pub enum ChargeRequestType {
|
||||
},
|
||||
New {
|
||||
product_id: crate::models::ids::ProductId,
|
||||
#[schema(value_type = String)]
|
||||
interval: Option<PriceDuration>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[derive(Deserialize, Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct PaymentRequestMetadata {
|
||||
#[serde(flatten)]
|
||||
@@ -1527,7 +1614,7 @@ pub struct PaymentRequestMetadata {
|
||||
pub affiliate_code: Option<AffiliateCodeId>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[derive(Deserialize, Serialize, utoipa::ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum PaymentRequestMetadataKind {
|
||||
Pyro {
|
||||
@@ -1537,16 +1624,23 @@ pub enum PaymentRequestMetadataKind {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct PaymentRequest {
|
||||
#[serde(flatten)]
|
||||
pub type_: PaymentRequestType,
|
||||
pub charge: ChargeRequestType,
|
||||
#[schema(value_type = String)]
|
||||
pub existing_payment_intent: Option<stripe::PaymentIntentId>,
|
||||
pub metadata: Option<PaymentRequestMetadata>,
|
||||
}
|
||||
|
||||
#[post("payment")]
|
||||
/// Initiate a payment.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
responses((status = OK, body = serde_json::Value))
|
||||
)]
|
||||
#[post("/payment")]
|
||||
pub async fn initiate_payment(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -1610,7 +1704,14 @@ pub async fn initiate_payment(
|
||||
}
|
||||
}
|
||||
|
||||
#[post("_stripe")]
|
||||
/// Receive a Stripe webhook.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
request_body(content = String, content_type = "text/plain"),
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/_stripe")]
|
||||
pub async fn stripe_webhook(
|
||||
req: HttpRequest,
|
||||
payload: String,
|
||||
@@ -2503,7 +2604,7 @@ async fn apply_credit_many(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct CreditRequest {
|
||||
#[serde(flatten)]
|
||||
pub target: CreditTarget,
|
||||
@@ -2512,7 +2613,7 @@ pub struct CreditRequest {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum CreditTarget {
|
||||
Subscriptions {
|
||||
@@ -2526,7 +2627,13 @@ pub enum CreditTarget {
|
||||
},
|
||||
}
|
||||
|
||||
#[post("credit")]
|
||||
/// Credit subscriptions.
|
||||
#[utoipa::path(
|
||||
context_path = "/billing",
|
||||
tag = "billing",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/credit")]
|
||||
pub async fn credit(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::{
|
||||
util::{error::Context, http::HttpClient, tiltify::TiltifyClient},
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(tiltify_webhook).service(pride_26);
|
||||
}
|
||||
|
||||
@@ -143,7 +143,13 @@ impl CampaignDonation {
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Receive a Tiltify webhook.
|
||||
#[utoipa::path(
|
||||
context_path = "/campaign",
|
||||
tag = "campaigns",
|
||||
request_body(content = String, content_type = "text/plain"),
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/webhook")]
|
||||
pub async fn tiltify_webhook(
|
||||
req: HttpRequest,
|
||||
@@ -301,7 +307,12 @@ fn verify_tiltify_webhook_signature(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Get Pride campaign data.
|
||||
#[utoipa::path(
|
||||
context_path = "/campaign",
|
||||
tag = "campaigns",
|
||||
responses((status = OK, body = CampaignInfo))
|
||||
)]
|
||||
#[get("/pride-26")]
|
||||
pub async fn pride_26(
|
||||
http: web::Data<HttpClient>,
|
||||
|
||||
@@ -35,7 +35,7 @@ use crate::{
|
||||
|
||||
pub mod rescan;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/delphi")
|
||||
.service(ingest_report)
|
||||
@@ -141,8 +141,14 @@ pub struct DelphiRunParameters {
|
||||
pub file_id: crate::models::ids::FileId,
|
||||
}
|
||||
|
||||
#[post("ingest", guard = "admin_key_guard")]
|
||||
async fn ingest_report(
|
||||
/// Ingest a Delphi report.
|
||||
#[utoipa::path(
|
||||
context_path = "/delphi",
|
||||
tag = "delphi",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/ingest", guard = "admin_key_guard")]
|
||||
pub async fn ingest_report(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
web::Json(report): web::Json<serde_json::Value>,
|
||||
@@ -466,8 +472,15 @@ pub async fn send_tech_review_exit_file_deleted_message_if_exited(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("run")]
|
||||
async fn _run(
|
||||
/// Run Delphi.
|
||||
#[utoipa::path(
|
||||
context_path = "/delphi",
|
||||
tag = "delphi",
|
||||
params(("file_id" = crate::models::ids::FileId, Query)),
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/run")]
|
||||
pub async fn _run(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -487,8 +500,14 @@ async fn _run(
|
||||
run(&**pool, run_parameters.into_inner(), &http).await
|
||||
}
|
||||
|
||||
#[get("version")]
|
||||
async fn version(
|
||||
/// Get the Delphi version.
|
||||
#[utoipa::path(
|
||||
context_path = "/delphi",
|
||||
tag = "delphi",
|
||||
responses((status = OK, body = inline(Option<i32>)))
|
||||
)]
|
||||
#[get("/version")]
|
||||
pub async fn version(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -510,8 +529,14 @@ async fn version(
|
||||
))
|
||||
}
|
||||
|
||||
#[get("issue_type/schema")]
|
||||
async fn issue_type_schema(
|
||||
/// Get the Delphi issue type schema.
|
||||
#[utoipa::path(
|
||||
context_path = "/delphi",
|
||||
tag = "delphi",
|
||||
responses((status = OK, body = serde_json::Value))
|
||||
)]
|
||||
#[get("/issue_type/schema")]
|
||||
pub async fn issue_type_schema(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
|
||||
@@ -28,14 +28,14 @@ use eyre::eyre;
|
||||
use lettre::message::Mailbox;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(create)
|
||||
.service(create_email_sync)
|
||||
.service(remove)
|
||||
.service(send_custom_email);
|
||||
}
|
||||
|
||||
#[derive(Deserialize, PartialEq, Default)]
|
||||
#[derive(Deserialize, PartialEq, Default, utoipa::ToSchema)]
|
||||
enum EmailStrategy {
|
||||
#[default]
|
||||
Async,
|
||||
@@ -43,8 +43,9 @@ enum EmailStrategy {
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
struct CreateNotification {
|
||||
#[schema(value_type = serde_json::Value)]
|
||||
pub body: NotificationBody,
|
||||
pub user_ids: Vec<UserId>,
|
||||
#[serde(default)]
|
||||
@@ -78,7 +79,12 @@ where
|
||||
error.as_api_error().serialize(serializer)
|
||||
}
|
||||
|
||||
#[post("external_notifications", guard = "external_notification_key_guard")]
|
||||
/// Create external notifications.
|
||||
#[utoipa::path(
|
||||
tag = "external notifications",
|
||||
responses((status = ACCEPTED))
|
||||
)]
|
||||
#[post("/external_notifications", guard = "external_notification_key_guard")]
|
||||
pub async fn create(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -89,6 +95,46 @@ pub async fn create(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Create notifications and send emails.
|
||||
///
|
||||
/// Responds with the user IDs that could not be emailed:
|
||||
/// - `200` if every recipient was emailed (empty list)
|
||||
/// - `207` if some recipients could not be emailed (list of failed IDs)
|
||||
/// Create email sync.
|
||||
#[utoipa::path(
|
||||
tag = "external notifications",
|
||||
responses(
|
||||
(status = OK, body = inline(Vec<UserId>)),
|
||||
(status = 207, body = inline(Vec<UserId>)),
|
||||
)
|
||||
)]
|
||||
#[post(
|
||||
"external_notifications/email-sync",
|
||||
guard = "external_notification_key_guard"
|
||||
)]
|
||||
pub async fn create_email_sync(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
email_queue: web::Data<EmailQueue>,
|
||||
data: web::Json<CreateNotification>,
|
||||
) -> Result<(web::Json<Vec<UserId>>, StatusCode), ApiError> {
|
||||
let data = data.into_inner();
|
||||
create_impl(
|
||||
pool,
|
||||
redis,
|
||||
email_queue,
|
||||
CreateNotification {
|
||||
body: data.body,
|
||||
user_ids: data.user_ids,
|
||||
email: EmailStrategy::Sync,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(|(res, code)| {
|
||||
(web::Json(res.into_inner().into_keys().collect()), code)
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_impl(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -224,44 +270,19 @@ async fn create_impl(
|
||||
Ok((web::Json(HashMap::new()), StatusCode::ACCEPTED))
|
||||
}
|
||||
|
||||
/// Inserts notifications for all users and tries to send emails immediately.
|
||||
///
|
||||
/// Responds with the user IDs that could not be emailed and a reason why
|
||||
#[post(
|
||||
"external_notifications/email-sync",
|
||||
guard = "external_notification_key_guard"
|
||||
)]
|
||||
pub async fn create_email_sync(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
email_queue: web::Data<EmailQueue>,
|
||||
data: web::Json<CreateNotification>,
|
||||
) -> Result<(web::Json<Vec<UserId>>, StatusCode), ApiError> {
|
||||
let data = data.into_inner();
|
||||
create_impl(
|
||||
pool,
|
||||
redis,
|
||||
email_queue,
|
||||
CreateNotification {
|
||||
body: data.body,
|
||||
user_ids: data.user_ids,
|
||||
email: EmailStrategy::Sync,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map(|(res, code)| {
|
||||
(web::Json(res.into_inner().into_keys().collect()), code)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
struct NotificationFilter {
|
||||
pub user_ids: Vec<UserId>,
|
||||
#[serde(flatten)]
|
||||
pub body: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[delete("external_notifications", guard = "external_notification_key_guard")]
|
||||
/// Remove external notifications.
|
||||
#[utoipa::path(
|
||||
tag = "external notifications",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[delete("/external_notifications", guard = "external_notification_key_guard")]
|
||||
pub async fn remove(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -301,7 +322,7 @@ pub async fn remove(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
struct SendEmail {
|
||||
pub users: Vec<UserId>,
|
||||
pub key: String,
|
||||
@@ -309,7 +330,12 @@ struct SendEmail {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[post("external_notifications/send_custom_email")]
|
||||
/// Send a custom email.
|
||||
#[utoipa::path(
|
||||
tag = "external notifications",
|
||||
responses((status = ACCEPTED))
|
||||
)]
|
||||
#[post("/external_notifications/send_custom_email")]
|
||||
pub async fn send_custom_email(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
|
||||
@@ -60,9 +60,9 @@ use webauthn_rs::prelude::{
|
||||
};
|
||||
use zxcvbn::Score;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(
|
||||
utoipa_actix_web::scope("/auth")
|
||||
web::scope("/auth")
|
||||
.service(init)
|
||||
.service(auth_callback)
|
||||
.service(delete_auth_provider)
|
||||
@@ -1068,12 +1068,15 @@ pub struct Authorization {
|
||||
// Init link takes us to GitHub API and calls back to callback endpoint with a code and state
|
||||
// http://localhost:8000/auth/init?url=https://modrinth.com
|
||||
#[utoipa::path(
|
||||
get,
|
||||
operation_id = "authInit",
|
||||
responses(
|
||||
(status = 307, description = "Redirect to OAuth provider"),
|
||||
(status = 400, description = "Invalid input")
|
||||
)
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
params(
|
||||
("url" = Url, Query),
|
||||
("provider" = Option<AuthProvider>, Query),
|
||||
("token" = Option<String>, Query),
|
||||
("auth_token" = Option<String>, Query)
|
||||
),
|
||||
responses((status = TEMPORARY_REDIRECT), (status = OK))
|
||||
)]
|
||||
#[get("/init")]
|
||||
pub async fn init(
|
||||
@@ -1166,12 +1169,8 @@ pub async fn init(
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
operation_id = "authCallback",
|
||||
responses(
|
||||
(status = 307, description = "Redirect with auth code"),
|
||||
(status = 401, description = "Authentication failed")
|
||||
)
|
||||
context_path = "/auth",
|
||||
tag = "auth", responses((status = OK))
|
||||
)]
|
||||
#[get("/callback")]
|
||||
pub async fn auth_callback(
|
||||
@@ -1441,16 +1440,19 @@ struct NewOAuthAccount {
|
||||
pub sign_up_newsletter: bool,
|
||||
}
|
||||
|
||||
/// Create account with OAuth.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "createOAuthAccount",
|
||||
responses(
|
||||
(status = 200, description = "OAuth account created"),
|
||||
(status = 400, description = "Invalid input")
|
||||
)
|
||||
operation_id = "createOAuthAccount",
|
||||
responses(
|
||||
(status = 200, description = "OAuth account created", body = serde_json::Value),
|
||||
(status = 400, description = "Invalid input")
|
||||
)
|
||||
)]
|
||||
#[post("/create/oauth")]
|
||||
async fn create_oauth_account(
|
||||
pub async fn create_oauth_account(
|
||||
req: HttpRequest,
|
||||
db: Data<PgPool>,
|
||||
file_host: Data<dyn FileHost>,
|
||||
@@ -1525,7 +1527,10 @@ struct DiscordCommunityHandoffPayload {
|
||||
nonce: String,
|
||||
}
|
||||
|
||||
/// Link Discord community.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
operation_id = "discordCommunityLink",
|
||||
responses(
|
||||
(status = 200, description = "Discord community bot handoff URL", body = DiscordCommunityLinkResponse),
|
||||
@@ -1599,7 +1604,10 @@ pub async fn discord_community_link(
|
||||
Ok(web::Json(DiscordCommunityLinkResponse { url }))
|
||||
}
|
||||
|
||||
/// Remove an auth provider.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
delete,
|
||||
operation_id = "deleteAuthProvider",
|
||||
responses(
|
||||
@@ -1939,11 +1947,14 @@ impl ReadyAccountRegisterFlow {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate password account creation.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "validateCreateAccountWithPassword",
|
||||
responses(
|
||||
(status = 200, description = "Account input is valid"),
|
||||
(status = NO_CONTENT, description = "Account input is valid"),
|
||||
(status = 400, description = "Invalid input")
|
||||
)
|
||||
)]
|
||||
@@ -1964,13 +1975,16 @@ pub async fn validate_create_account_with_password(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create account with a password.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "createAccountPassword",
|
||||
responses(
|
||||
(status = 200, description = "Account created"),
|
||||
(status = 400, description = "Invalid input")
|
||||
)
|
||||
operation_id = "createAccountPassword",
|
||||
responses(
|
||||
(status = 200, description = "Account created", body = serde_json::Value),
|
||||
(status = 400, description = "Invalid input")
|
||||
)
|
||||
)]
|
||||
#[post("/create")]
|
||||
pub async fn create_account_with_password(
|
||||
@@ -2010,13 +2024,16 @@ pub struct Login {
|
||||
pub challenge: String,
|
||||
}
|
||||
|
||||
/// Log in with a password.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "loginPassword",
|
||||
responses(
|
||||
(status = 200, description = "Login successful"),
|
||||
(status = 401, description = "Invalid credentials")
|
||||
)
|
||||
operation_id = "loginPassword",
|
||||
responses(
|
||||
(status = 200, description = "Login successful", body = serde_json::Value),
|
||||
(status = 401, description = "Invalid credentials")
|
||||
)
|
||||
)]
|
||||
#[post("/login")]
|
||||
pub async fn login_password(
|
||||
@@ -2168,13 +2185,16 @@ async fn validate_2fa_code(
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete login with 2FA.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "login2fa",
|
||||
responses(
|
||||
(status = 200, description = "2FA login successful"),
|
||||
(status = 401, description = "Invalid credentials")
|
||||
)
|
||||
operation_id = "login2fa",
|
||||
responses(
|
||||
(status = 200, description = "2FA login successful", body = serde_json::Value),
|
||||
(status = 401, description = "Invalid credentials")
|
||||
)
|
||||
)]
|
||||
#[post("/login/2fa")]
|
||||
pub async fn login_2fa(
|
||||
@@ -2225,13 +2245,16 @@ pub async fn login_2fa(
|
||||
}
|
||||
}
|
||||
|
||||
/// Start 2FA setup.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "begin2faFlow",
|
||||
responses(
|
||||
(status = 200, description = "2FA secret generated"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
operation_id = "begin2faFlow",
|
||||
responses(
|
||||
(status = 200, description = "2FA secret generated", body = serde_json::Value),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
#[post("/2fa/get_secret")]
|
||||
@@ -2273,13 +2296,16 @@ pub async fn begin_2fa_flow(
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish 2FA setup.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "finish2faFlow",
|
||||
responses(
|
||||
(status = 200, description = "2FA enabled"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
operation_id = "finish2faFlow",
|
||||
responses(
|
||||
(status = 200, description = "2FA enabled", body = serde_json::Value),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
#[post("/2fa")]
|
||||
@@ -2405,7 +2431,10 @@ pub struct Remove2FA {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// Remove 2FA.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
delete,
|
||||
operation_id = "remove2fa",
|
||||
responses(
|
||||
@@ -2502,7 +2531,10 @@ pub struct ResetPassword {
|
||||
pub challenge: String,
|
||||
}
|
||||
|
||||
/// Start password reset.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "resetPasswordBegin",
|
||||
responses(
|
||||
@@ -2605,7 +2637,10 @@ pub struct ChangePassword {
|
||||
pub new_password: Option<String>,
|
||||
}
|
||||
|
||||
/// Change password.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
patch,
|
||||
operation_id = "changePassword",
|
||||
responses(
|
||||
@@ -2768,7 +2803,10 @@ pub struct SetEmail {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// Set email address.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
patch,
|
||||
operation_id = "setEmail",
|
||||
responses(
|
||||
@@ -2887,7 +2925,10 @@ pub async fn set_email(
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
/// Resend verification email.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "resendVerifyEmail",
|
||||
responses(
|
||||
@@ -2959,7 +3000,10 @@ pub struct VerifyEmail {
|
||||
pub flow: String,
|
||||
}
|
||||
|
||||
/// Verify email address.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "verifyEmail",
|
||||
responses(
|
||||
@@ -3022,7 +3066,10 @@ pub async fn verify_email(
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to the newsletter.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "subscribeNewsletter",
|
||||
responses(
|
||||
@@ -3068,13 +3115,16 @@ pub async fn subscribe_newsletter(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
/// Get newsletter subscription status.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
get,
|
||||
operation_id = "getNewsletterSubscriptionStatus",
|
||||
responses(
|
||||
(status = 200, description = "Subscription status"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
operation_id = "getNewsletterSubscriptionStatus",
|
||||
responses(
|
||||
(status = 200, description = "Subscription status", body = serde_json::Value),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
#[get("/email/subscribe")]
|
||||
@@ -3115,7 +3165,10 @@ pub struct RegisterPasskeyResponse {
|
||||
pub flow: String,
|
||||
}
|
||||
|
||||
/// Start passkey registration.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "registerPasskeyStart",
|
||||
responses(
|
||||
@@ -3212,7 +3265,10 @@ pub struct PasskeyResponse {
|
||||
pub last_used: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Finish passkey registration.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "registerPasskeyFinish",
|
||||
responses(
|
||||
@@ -3318,7 +3374,10 @@ pub struct AuthenticatePasskeyResponse {
|
||||
pub flow: String,
|
||||
}
|
||||
|
||||
/// Start passkey authentication.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "authenticatePasskeyStart",
|
||||
responses(
|
||||
@@ -3358,13 +3417,16 @@ pub struct AuthenticatePasskeyFinish {
|
||||
pub credential: PublicKeyCredential,
|
||||
}
|
||||
|
||||
/// Finish passkey authentication.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
post,
|
||||
operation_id = "authenticatePasskeyFinish",
|
||||
responses(
|
||||
(status = 200, description = "Passkey authentication successful"),
|
||||
(status = 400, description = "Invalid input")
|
||||
)
|
||||
operation_id = "authenticatePasskeyFinish",
|
||||
responses(
|
||||
(status = 200, description = "Passkey authentication successful", body = serde_json::Value),
|
||||
(status = 400, description = "Invalid input")
|
||||
)
|
||||
)]
|
||||
#[post("/passkey/finish")]
|
||||
pub async fn authenticate_passkey_finish(
|
||||
@@ -3472,7 +3534,10 @@ pub async fn authenticate_passkey_finish(
|
||||
}
|
||||
}
|
||||
|
||||
/// List passkeys.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
get,
|
||||
operation_id = "listPasskeys",
|
||||
responses(
|
||||
@@ -3519,7 +3584,10 @@ pub struct RenamePasskey {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Rename a passkey.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
patch,
|
||||
operation_id = "renamePasskey",
|
||||
responses(
|
||||
@@ -3571,7 +3639,10 @@ pub async fn rename_passkey(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
/// Delete a passkey.
|
||||
#[utoipa::path(
|
||||
context_path = "/auth",
|
||||
tag = "auth",
|
||||
delete,
|
||||
operation_id = "deletePasskey",
|
||||
responses(
|
||||
|
||||
@@ -6,10 +6,16 @@ use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use actix_web::{HttpRequest, HttpResponse, post, web};
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(web::scope("/gdpr").service(export));
|
||||
}
|
||||
|
||||
/// Export GDPR data.
|
||||
#[utoipa::path(
|
||||
context_path = "/gdpr",
|
||||
tag = "GDPR",
|
||||
responses((status = OK, body = serde_json::Value))
|
||||
)]
|
||||
#[post("/export")]
|
||||
pub async fn export(
|
||||
req: HttpRequest,
|
||||
|
||||
@@ -10,7 +10,7 @@ use chrono::{Datelike, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(get_globals);
|
||||
}
|
||||
|
||||
@@ -89,8 +89,12 @@ pub fn tax_compliance_payout_threshold_for_year(
|
||||
value
|
||||
}
|
||||
|
||||
/// Gets configured global non-secret variables for this backend instance.
|
||||
#[utoipa::path]
|
||||
/// Get backend globals.
|
||||
#[utoipa::path(
|
||||
context_path = "/globals",
|
||||
tag = "globals",
|
||||
responses((status = OK, body = Globals))
|
||||
)]
|
||||
#[get("")]
|
||||
pub async fn get_globals() -> web::Json<Globals> {
|
||||
web::Json(GLOBALS.clone())
|
||||
|
||||
@@ -28,6 +28,12 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(success_callback).service(error_callback);
|
||||
}
|
||||
|
||||
/// Receive a Gotenberg success callback.
|
||||
#[utoipa::path(
|
||||
tag = "gotenberg",
|
||||
request_body = Vec<u8>,
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/gotenberg/success", guard = "internal_network_guard")]
|
||||
pub async fn success_callback(
|
||||
web::Header(header::ContentDisposition {
|
||||
@@ -82,7 +88,7 @@ pub async fn success_callback(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Error, utoipa::ToSchema)]
|
||||
pub struct GotenbergError {
|
||||
pub status: Option<String>,
|
||||
pub message: Option<String>,
|
||||
@@ -101,6 +107,11 @@ impl fmt::Display for GotenbergError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive a Gotenberg error callback.
|
||||
#[utoipa::path(
|
||||
tag = "gotenberg",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/gotenberg/error", guard = "internal_network_guard")]
|
||||
pub async fn error_callback(
|
||||
web::Header(GotenbergTrace(trace)): web::Header<GotenbergTrace>,
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::queue::billing::try_process_user_redeemal;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::guards::medal_key_guard;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(web::scope("/medal").service(verify).service(redeem));
|
||||
}
|
||||
|
||||
@@ -22,7 +22,19 @@ struct MedalQuery {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[post("verify", guard = "medal_key_guard")]
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
struct VerifyResponse {
|
||||
user_id: UserId,
|
||||
redeemed: bool,
|
||||
}
|
||||
|
||||
/// Verify Medal credentials.
|
||||
#[utoipa::path(
|
||||
context_path = "/medal",
|
||||
tag = "medal",
|
||||
responses((status = OK, body = VerifyResponse))
|
||||
)]
|
||||
#[post("/verify", guard = "medal_key_guard")]
|
||||
pub async fn verify(
|
||||
pool: web::Data<PgPool>,
|
||||
web::Query(MedalQuery { username }): web::Query<MedalQuery>,
|
||||
@@ -35,12 +47,6 @@ pub async fn verify(
|
||||
)
|
||||
.await?;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct VerifyResponse {
|
||||
user_id: UserId,
|
||||
redeemed: bool,
|
||||
}
|
||||
|
||||
match maybe_fields {
|
||||
None => Err(ApiError::NotFound),
|
||||
Some(fields) => Ok(HttpResponse::Ok().json(VerifyResponse {
|
||||
@@ -50,7 +56,13 @@ pub async fn verify(
|
||||
}
|
||||
}
|
||||
|
||||
#[post("redeem", guard = "medal_key_guard")]
|
||||
/// Redeem Medal credit.
|
||||
#[utoipa::path(
|
||||
context_path = "/medal",
|
||||
tag = "medal",
|
||||
responses((status = ACCEPTED), (status = CREATED))
|
||||
)]
|
||||
#[post("/redeem", guard = "medal_key_guard")]
|
||||
pub async fn redeem(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
|
||||
@@ -19,99 +19,172 @@ pub mod session;
|
||||
pub mod statuses;
|
||||
|
||||
pub use super::ApiError;
|
||||
use super::SecurityAddon;
|
||||
use super::v3::oauth_clients;
|
||||
use crate::util::cors::default_cors;
|
||||
use actix_web::web;
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
actix_web::web::scope("/_internal")
|
||||
web::scope("/_internal")
|
||||
.wrap(default_cors())
|
||||
.configure(|cfg| {
|
||||
cfg.service(
|
||||
actix_web::web::scope("/admin")
|
||||
.service(admin::count_download)
|
||||
.service(admin::force_reindex)
|
||||
.service(admin::force_reindex_project),
|
||||
);
|
||||
cfg.service(
|
||||
actix_web::web::scope("/session")
|
||||
.service(session::list)
|
||||
.service(session::delete)
|
||||
.service(session::refresh),
|
||||
);
|
||||
cfg.service(
|
||||
actix_web::web::scope("/auth")
|
||||
.service(flows::init)
|
||||
.service(flows::auth_callback)
|
||||
.service(flows::delete_auth_provider)
|
||||
.service(flows::create_account_with_password)
|
||||
.service(flows::login_password)
|
||||
.service(flows::login_2fa)
|
||||
.service(flows::begin_2fa_flow)
|
||||
.service(flows::finish_2fa_flow)
|
||||
.service(flows::remove_2fa)
|
||||
.service(flows::reset_password_begin)
|
||||
.service(flows::change_password)
|
||||
.service(flows::resend_verify_email)
|
||||
.service(flows::set_email)
|
||||
.service(flows::verify_email)
|
||||
.service(flows::subscribe_newsletter)
|
||||
.service(flows::get_newsletter_subscription_status)
|
||||
.service(flows::discord_community_link),
|
||||
);
|
||||
cfg.service(pats::get_pats);
|
||||
cfg.service(pats::create_pat);
|
||||
cfg.service(pats::edit_pat);
|
||||
cfg.service(pats::delete_pat);
|
||||
})
|
||||
.configure(admin::config)
|
||||
.configure(session::config)
|
||||
.configure(flows::config)
|
||||
.configure(pats::config)
|
||||
.configure(oauth_clients::config)
|
||||
.service(web::scope("/moderation").configure(moderation::config))
|
||||
.service(web::scope("/affiliate").configure(affiliate::config))
|
||||
.service(web::scope("/campaign").configure(campaign::config))
|
||||
.service(web::scope("/search-management").configure(search::config))
|
||||
.service(web::scope("/globals").configure(globals::config))
|
||||
.service(web::scope("/server-ping").configure(server_ping::config))
|
||||
.service(web::scope("/attribution").configure(attribution::config))
|
||||
.configure(billing::config)
|
||||
.configure(delphi::config)
|
||||
.configure(external_notifications::config)
|
||||
.configure(gdpr::config)
|
||||
.configure(gotenberg::config)
|
||||
.configure(statuses::config)
|
||||
.configure(medal::config)
|
||||
.configure(external_notifications::config)
|
||||
.configure(mural::config)
|
||||
.configure(delphi::config),
|
||||
.configure(statuses::config),
|
||||
)
|
||||
.service(
|
||||
web::scope("/v3/analytics-event")
|
||||
.wrap(default_cors())
|
||||
.configure(super::v3::analytics_event::config),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn utoipa_config(
|
||||
cfg: &mut utoipa_actix_web::service_config::ServiceConfig,
|
||||
) {
|
||||
cfg.service(
|
||||
utoipa_actix_web::scope("/_internal/moderation")
|
||||
.wrap(default_cors())
|
||||
.configure(moderation::config),
|
||||
)
|
||||
.service(
|
||||
utoipa_actix_web::scope("/_internal/affiliate")
|
||||
.wrap(default_cors())
|
||||
.configure(affiliate::config),
|
||||
)
|
||||
.service(
|
||||
utoipa_actix_web::scope("/_internal/campaign")
|
||||
.wrap(default_cors())
|
||||
.configure(campaign::config),
|
||||
)
|
||||
.service(
|
||||
utoipa_actix_web::scope("/_internal/search-management")
|
||||
.wrap(default_cors())
|
||||
.configure(search::config),
|
||||
)
|
||||
.service(
|
||||
utoipa_actix_web::scope("/_internal/globals")
|
||||
.wrap(default_cors())
|
||||
.configure(globals::config),
|
||||
)
|
||||
.service(
|
||||
utoipa_actix_web::scope("/_internal/server-ping")
|
||||
.wrap(default_cors())
|
||||
.configure(server_ping::config),
|
||||
)
|
||||
.service(
|
||||
utoipa_actix_web::scope("/_internal/attribution")
|
||||
.wrap(default_cors())
|
||||
.configure(attribution::config),
|
||||
);
|
||||
#[derive(utoipa::OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "Internal API (UNSTABLE)",
|
||||
version = "internal",
|
||||
description = include_str!("../../api_internal_description.md"),
|
||||
),
|
||||
paths(
|
||||
admin::count_download,
|
||||
admin::force_reindex,
|
||||
admin::force_reindex_project,
|
||||
session::list,
|
||||
session::delete,
|
||||
session::refresh,
|
||||
flows::init,
|
||||
flows::auth_callback,
|
||||
flows::create_oauth_account,
|
||||
flows::discord_community_link,
|
||||
flows::delete_auth_provider,
|
||||
flows::validate_create_account_with_password,
|
||||
flows::create_account_with_password,
|
||||
flows::login_password,
|
||||
flows::login_2fa,
|
||||
flows::begin_2fa_flow,
|
||||
flows::finish_2fa_flow,
|
||||
flows::remove_2fa,
|
||||
flows::reset_password_begin,
|
||||
flows::change_password,
|
||||
flows::set_email,
|
||||
flows::resend_verify_email,
|
||||
flows::verify_email,
|
||||
flows::subscribe_newsletter,
|
||||
flows::get_newsletter_subscription_status,
|
||||
flows::register_passkey_start,
|
||||
flows::register_passkey_finish,
|
||||
flows::authenticate_passkey_start,
|
||||
flows::authenticate_passkey_finish,
|
||||
flows::list_passkeys,
|
||||
flows::rename_passkey,
|
||||
flows::delete_passkey,
|
||||
pats::get_pats,
|
||||
pats::create_pat,
|
||||
pats::edit_pat,
|
||||
pats::delete_pat,
|
||||
moderation::get_projects,
|
||||
moderation::get_project_meta,
|
||||
moderation::set_project_meta,
|
||||
moderation::acquire_lock,
|
||||
moderation::override_lock,
|
||||
moderation::get_lock_status,
|
||||
moderation::release_lock,
|
||||
moderation::release_lock_beacon,
|
||||
moderation::delete_all_locks,
|
||||
moderation::tech_review::get_issue,
|
||||
moderation::tech_review::get_report,
|
||||
moderation::tech_review::search_projects,
|
||||
moderation::tech_review::get_project_report,
|
||||
moderation::tech_review::submit_report,
|
||||
moderation::tech_review::update_issue_details,
|
||||
moderation::tech_review::add_report,
|
||||
moderation::external_license::search,
|
||||
moderation::external_license::lookup,
|
||||
moderation::external_license::get_by_sha1,
|
||||
moderation::external_license::get_by_sha1_bulk,
|
||||
moderation::external_license::add_file,
|
||||
moderation::external_license::reassign_file,
|
||||
moderation::external_license::update_license,
|
||||
affiliate::ingest_click,
|
||||
affiliate::get_all,
|
||||
affiliate::create,
|
||||
affiliate::get,
|
||||
affiliate::delete,
|
||||
affiliate::patch,
|
||||
campaign::tiltify_webhook,
|
||||
campaign::pride_26,
|
||||
search::tasks,
|
||||
search::tasks_cancel,
|
||||
globals::get_globals,
|
||||
server_ping::ping_minecraft_java,
|
||||
attribution::scan,
|
||||
attribution::list,
|
||||
attribution::update_group,
|
||||
attribution::assign,
|
||||
attribution::split,
|
||||
billing::products,
|
||||
billing::subscriptions,
|
||||
billing::refund_charge,
|
||||
billing::reprocess_charge_tax,
|
||||
billing::edit_subscription,
|
||||
billing::user_customer,
|
||||
billing::charges,
|
||||
billing::add_payment_method_flow,
|
||||
billing::edit_payment_method,
|
||||
billing::remove_payment_method,
|
||||
billing::payment_methods,
|
||||
billing::active_servers,
|
||||
billing::initiate_payment,
|
||||
billing::stripe_webhook,
|
||||
billing::credit,
|
||||
delphi::ingest_report,
|
||||
delphi::_run,
|
||||
delphi::version,
|
||||
delphi::issue_type_schema,
|
||||
external_notifications::create,
|
||||
external_notifications::create_email_sync,
|
||||
external_notifications::remove,
|
||||
external_notifications::send_custom_email,
|
||||
gdpr::export,
|
||||
gotenberg::success_callback,
|
||||
gotenberg::error_callback,
|
||||
medal::verify,
|
||||
medal::redeem,
|
||||
mural::get_bank_details,
|
||||
statuses::ws_init,
|
||||
super::v3::analytics_event::analytics_events_get,
|
||||
super::v3::analytics_event::analytics_event_create,
|
||||
super::v3::analytics_event::analytics_event_edit,
|
||||
super::v3::analytics_event::analytics_event_delete,
|
||||
),
|
||||
modifiers(&InternalPathModifier, &SecurityAddon)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
struct InternalPathModifier;
|
||||
|
||||
impl utoipa::Modify for InternalPathModifier {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
super::prefix_openapi_paths(openapi, "/_internal", |path| {
|
||||
path.starts_with("/v3/")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::queue::moderation::ApprovalType;
|
||||
use crate::routes::ApiError;
|
||||
use crate::{auth::check_is_moderator_from_headers, queue::session::AuthQueue};
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(search)
|
||||
.service(get_by_sha1)
|
||||
.service(get_by_sha1_bulk)
|
||||
@@ -329,9 +329,14 @@ async fn fetch_by_flame_ids(
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Search external licenses.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/external-license",
|
||||
tag = "moderation",
|
||||
responses((status = OK, body = inline(Vec<ExternalProject>)))
|
||||
)]
|
||||
#[post("/search")]
|
||||
async fn search(
|
||||
pub async fn search(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -393,9 +398,14 @@ async fn search(
|
||||
Ok(web::Json(results))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Look up external license metadata.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/external-license",
|
||||
tag = "moderation",
|
||||
responses((status = OK, body = ExternalLicenseLookupResponse))
|
||||
)]
|
||||
#[post("/lookup")]
|
||||
async fn lookup(
|
||||
pub async fn lookup(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -422,9 +432,14 @@ async fn lookup(
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Get external license by SHA-1.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/external-license",
|
||||
tag = "moderation",
|
||||
responses((status = OK, body = ExternalProject))
|
||||
)]
|
||||
#[get("/by-sha1/{sha1}")]
|
||||
async fn get_by_sha1(
|
||||
pub async fn get_by_sha1(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -448,9 +463,14 @@ async fn get_by_sha1(
|
||||
Ok(web::Json(result))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Get external licenses by SHA-1.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/external-license",
|
||||
tag = "moderation",
|
||||
responses((status = OK, body = inline(HashMap<String, ExternalProject>)))
|
||||
)]
|
||||
#[post("/by-sha1")]
|
||||
async fn get_by_sha1_bulk(
|
||||
pub async fn get_by_sha1_bulk(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -472,9 +492,14 @@ async fn get_by_sha1_bulk(
|
||||
Ok(web::Json(results))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Add an external license file.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/external-license",
|
||||
tag = "moderation",
|
||||
responses((status = OK, body = ExternalProject))
|
||||
)]
|
||||
#[post("/file")]
|
||||
async fn add_file(
|
||||
pub async fn add_file(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -484,9 +509,14 @@ async fn add_file(
|
||||
upsert_file_license(req, pool, redis, session_queue, body).await
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Reassign an external license file.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/external-license",
|
||||
tag = "moderation",
|
||||
responses((status = OK, body = ExternalProject))
|
||||
)]
|
||||
#[post("/file/reassign")]
|
||||
async fn reassign_file(
|
||||
pub async fn reassign_file(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -584,9 +614,14 @@ async fn upsert_file_license(
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Update an external license.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/external-license",
|
||||
tag = "moderation",
|
||||
responses((status = OK, body = ExternalProject))
|
||||
)]
|
||||
#[patch("/{id}")]
|
||||
async fn update_license(
|
||||
pub async fn update_license(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
|
||||
@@ -21,11 +21,11 @@ use ownership::get_projects_ownership;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
mod external_license;
|
||||
pub mod external_license;
|
||||
mod ownership;
|
||||
mod tech_review;
|
||||
pub mod tech_review;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(get_projects)
|
||||
.service(get_project_meta)
|
||||
.service(set_project_meta)
|
||||
@@ -35,13 +35,9 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
.service(release_lock)
|
||||
.service(release_lock_beacon)
|
||||
.service(delete_all_locks)
|
||||
.service(web::scope("/tech-review").configure(tech_review::config))
|
||||
.service(
|
||||
utoipa_actix_web::scope("/tech-review")
|
||||
.configure(tech_review::config),
|
||||
)
|
||||
.service(
|
||||
utoipa_actix_web::scope("/external-license")
|
||||
.configure(external_license::config),
|
||||
web::scope("/external-license").configure(external_license::config),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,12 +158,19 @@ pub struct DeleteAllLocksResponse {
|
||||
pub deleted_count: u64,
|
||||
}
|
||||
|
||||
/// Fetch all projects which are in the moderation queue.
|
||||
/// List projects in the moderation queue.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation",
|
||||
tag = "moderation",
|
||||
params(
|
||||
("count" = Option<u16>, Query),
|
||||
("offset" = Option<u32>, Query),
|
||||
("has_external_dependencies" = Option<bool>, Query)
|
||||
),
|
||||
responses((status = OK, body = inline(Vec<FetchedProject>)))
|
||||
)]
|
||||
#[get("/projects")]
|
||||
async fn get_projects(
|
||||
pub async fn get_projects(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -291,12 +294,14 @@ pub async fn get_projects_internal(
|
||||
Ok(web::Json(projects))
|
||||
}
|
||||
|
||||
/// Fetch moderation metadata for a specific project.
|
||||
/// Get project moderation metadata.
|
||||
#[utoipa::path(
|
||||
responses((status = OK, body = inline(Vec<Project>)))
|
||||
context_path = "/moderation",
|
||||
tag = "moderation",
|
||||
responses((status = OK, body = MissingMetadata))
|
||||
)]
|
||||
#[get("/project/{id}")]
|
||||
async fn get_project_meta(
|
||||
pub async fn get_project_meta(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -447,10 +452,14 @@ pub enum Judgement {
|
||||
},
|
||||
}
|
||||
|
||||
/// Update moderation judgements for projects in the review queue.
|
||||
#[utoipa::path]
|
||||
/// Update project moderation judgements.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation",
|
||||
tag = "moderation",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/project")]
|
||||
async fn set_project_meta(
|
||||
pub async fn set_project_meta(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -536,16 +545,18 @@ async fn set_project_meta(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Acquire or refresh a moderation lock on a project.
|
||||
/// Acquire a moderation lock.
|
||||
/// Returns success if acquired, or info about who holds the lock if blocked.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation",
|
||||
tag = "moderation",
|
||||
responses(
|
||||
(status = OK, body = LockAcquireResponse),
|
||||
(status = NOT_FOUND, description = "Project not found")
|
||||
)
|
||||
)]
|
||||
#[post("/lock/{project_id}")]
|
||||
async fn acquire_lock(
|
||||
pub async fn acquire_lock(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -594,15 +605,17 @@ async fn acquire_lock(
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-acquire a moderation lock on a project (moderator override).
|
||||
/// Override a moderation lock.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation",
|
||||
tag = "moderation",
|
||||
responses(
|
||||
(status = OK, body = LockAcquireResponse),
|
||||
(status = NOT_FOUND, description = "Project not found")
|
||||
)
|
||||
)]
|
||||
#[post("/lock/{project_id}/override")]
|
||||
async fn override_lock(
|
||||
pub async fn override_lock(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -639,15 +652,17 @@ async fn override_lock(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check the lock status for a project
|
||||
/// Get moderation lock status.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation",
|
||||
tag = "moderation",
|
||||
responses(
|
||||
(status = OK, body = LockStatusResponse),
|
||||
(status = NOT_FOUND, description = "Project not found")
|
||||
)
|
||||
)]
|
||||
#[get("/lock/{project_id}")]
|
||||
async fn get_lock_status(
|
||||
pub async fn get_lock_status(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -699,15 +714,17 @@ async fn get_lock_status(
|
||||
}
|
||||
}
|
||||
|
||||
/// Release a moderation lock on a project
|
||||
/// Release a moderation lock.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation",
|
||||
tag = "moderation",
|
||||
responses(
|
||||
(status = OK, body = LockReleaseResponse),
|
||||
(status = NOT_FOUND, description = "Project not found")
|
||||
)
|
||||
)]
|
||||
#[delete("/lock/{project_id}")]
|
||||
async fn release_lock(
|
||||
pub async fn release_lock(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -740,12 +757,14 @@ async fn release_lock(
|
||||
Ok(web::Json(LockReleaseResponse { success: released }))
|
||||
}
|
||||
|
||||
/// Release a moderation lock using credentials in the request body.
|
||||
/// Release a moderation lock by beacon.
|
||||
///
|
||||
/// For use with `navigator.sendBeacon`, which cannot set `Authorization` or send `DELETE`.
|
||||
/// The body must be `text/plain` containing the same token value as the `Authorization` header
|
||||
/// (optional `Bearer ` prefix). This avoids a CORS preflight compared to `application/json`.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation",
|
||||
tag = "moderation",
|
||||
request_body(
|
||||
content = String,
|
||||
description = "Token value (same as Authorization header)",
|
||||
@@ -757,7 +776,7 @@ async fn release_lock(
|
||||
)
|
||||
)]
|
||||
#[post("/lock/{project_id}/release")]
|
||||
async fn release_lock_beacon(
|
||||
pub async fn release_lock_beacon(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -811,15 +830,17 @@ async fn release_lock_beacon(
|
||||
Ok(web::Json(LockReleaseResponse { success: released }))
|
||||
}
|
||||
|
||||
/// Delete all moderation locks (admin only)
|
||||
/// Delete all moderation locks.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation",
|
||||
tag = "moderation",
|
||||
responses(
|
||||
(status = OK, body = DeleteAllLocksResponse),
|
||||
(status = UNAUTHORIZED, description = "Not an admin")
|
||||
)
|
||||
)]
|
||||
#[delete("/locks")]
|
||||
async fn delete_all_locks(
|
||||
pub async fn delete_all_locks(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
|
||||
@@ -37,7 +37,7 @@ use crate::{
|
||||
};
|
||||
use eyre::eyre;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(search_projects)
|
||||
.service(get_project_report)
|
||||
.service(get_report)
|
||||
@@ -193,13 +193,15 @@ pub enum FlagReason {
|
||||
Delphi,
|
||||
}
|
||||
|
||||
/// Get info on an issue in a Delphi report.
|
||||
/// Get a Delphi report issue.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
tag = "moderation",
|
||||
security(("bearer_auth" = [])),
|
||||
responses((status = OK, body = inline(FileIssue)))
|
||||
)]
|
||||
#[get("/issue/{issue_id}")]
|
||||
async fn get_issue(
|
||||
pub async fn get_issue(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -252,13 +254,15 @@ async fn get_issue(
|
||||
Ok(web::Json(row.data.0))
|
||||
}
|
||||
|
||||
/// Get info on a specific report for a project.
|
||||
/// Get a project technical report.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
tag = "moderation",
|
||||
security(("bearer_auth" = [])),
|
||||
responses((status = OK, body = inline(FileReport)))
|
||||
)]
|
||||
#[get("/report/{id}")]
|
||||
async fn get_report(
|
||||
pub async fn get_report(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -662,13 +666,15 @@ async fn fetch_project_reports(
|
||||
Ok(project_reports)
|
||||
}
|
||||
|
||||
/// Searches all projects which are awaiting technical review.
|
||||
/// Search projects awaiting technical review.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
tag = "moderation",
|
||||
security(("bearer_auth" = [])),
|
||||
responses((status = OK, body = inline(Vec<SearchResponse>)))
|
||||
responses((status = OK, body = SearchResponse))
|
||||
)]
|
||||
#[post("/search")]
|
||||
async fn search_projects(
|
||||
pub async fn search_projects(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -872,13 +878,15 @@ async fn search_projects(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Gets the technical review report for a specific project.
|
||||
/// Get a project technical review report.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
tag = "moderation",
|
||||
security(("bearer_auth" = [])),
|
||||
responses((status = OK, body = inline(ProjectReportResponse)))
|
||||
)]
|
||||
#[get("/project/{id}")]
|
||||
async fn get_project_report(
|
||||
pub async fn get_project_report(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -963,18 +971,20 @@ pub struct SubmitReport {
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Submits a verdict for a project based on its technical reports.
|
||||
/// Submit a technical review verdict.
|
||||
///
|
||||
/// Before this is called, all issues for this project's reports must have been
|
||||
/// marked as either safe or unsafe. Otherwise, this will error with
|
||||
/// [`ApiError::TechReviewIssuesWithNoVerdict`], providing the issue IDs which
|
||||
/// are still unmarked.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
tag = "moderation",
|
||||
security(("bearer_auth" = [])),
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/submit/{project_id}")]
|
||||
async fn submit_report(
|
||||
pub async fn submit_report(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -1163,16 +1173,18 @@ pub struct UpdateIssue {
|
||||
pub verdict: DelphiVerdict,
|
||||
}
|
||||
|
||||
/// Updates the state of a technical review issue detail.
|
||||
/// Update technical review issue details.
|
||||
///
|
||||
/// This will not automatically reject the project for malware, but just flag
|
||||
/// this issue with a verdict.
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
tag = "moderation",
|
||||
security(("bearer_auth" = [])),
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[patch("/issue-detail")]
|
||||
async fn update_issue_details(
|
||||
pub async fn update_issue_details(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
@@ -1273,11 +1285,15 @@ pub struct AddReport {
|
||||
pub file_id: FileId,
|
||||
}
|
||||
|
||||
/// Adds a file to the technical review queue by adding an empty report, if one
|
||||
/// Add a technical review report.
|
||||
/// does not already exist for it.
|
||||
#[utoipa::path]
|
||||
#[utoipa::path(
|
||||
context_path = "/moderation/tech-review",
|
||||
tag = "moderation",
|
||||
responses((status = OK, body = DelphiReportId))
|
||||
)]
|
||||
#[put("/report")]
|
||||
async fn add_report(
|
||||
pub async fn add_report(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
|
||||
@@ -6,12 +6,17 @@ use crate::{
|
||||
queue::payouts::PayoutsQueue, routes::ApiError, util::error::Context,
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(get_bank_details);
|
||||
}
|
||||
|
||||
/// Get bank details.
|
||||
#[utoipa::path(
|
||||
tag = "mural",
|
||||
responses((status = OK, body = serde_json::Value))
|
||||
)]
|
||||
#[get("/mural/bank-details")]
|
||||
async fn get_bank_details(
|
||||
pub async fn get_bank_details(
|
||||
payouts_queue: web::Data<PayoutsQueue>,
|
||||
) -> Result<web::Json<muralpay::BankDetailsResponse>, ApiError> {
|
||||
let mural = payouts_queue.muralpay.load();
|
||||
|
||||
@@ -22,20 +22,22 @@ use crate::util::validate::validation_errors_to_string;
|
||||
use serde::Deserialize;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(get_pats);
|
||||
cfg.service(create_pat);
|
||||
cfg.service(edit_pat);
|
||||
cfg.service(delete_pat);
|
||||
}
|
||||
|
||||
/// List personal access tokens.
|
||||
#[utoipa::path(
|
||||
tag = "personal access tokens",
|
||||
get,
|
||||
operation_id = "getPats",
|
||||
responses(
|
||||
(status = 200, description = "List of PATs"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
operation_id = "getPats",
|
||||
responses(
|
||||
(status = 200, description = "List of PATs", body = serde_json::Value),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
security(("bearer_auth" = ["PAT_READ"]))
|
||||
)]
|
||||
#[get("/pat")]
|
||||
@@ -82,14 +84,16 @@ pub struct NewPersonalAccessToken {
|
||||
pub expires: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Create a personal access token.
|
||||
#[utoipa::path(
|
||||
tag = "personal access tokens",
|
||||
post,
|
||||
operation_id = "createPat",
|
||||
responses(
|
||||
(status = 200, description = "PAT created"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
operation_id = "createPat",
|
||||
responses(
|
||||
(status = 200, description = "PAT created", body = serde_json::Value),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
security(("bearer_auth" = ["PAT_CREATE"]))
|
||||
)]
|
||||
#[post("/pat")]
|
||||
@@ -185,10 +189,14 @@ pub struct ModifyPersonalAccessToken {
|
||||
pub expires: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Update a personal access token.
|
||||
#[utoipa::path(
|
||||
tag = "personal access tokens",
|
||||
patch,
|
||||
operation_id = "editPat",
|
||||
params(("id" = String, Path, description = "The PAT ID")),
|
||||
params(
|
||||
("id" = String, Path, description = "The PAT ID")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "PAT updated"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
@@ -293,10 +301,14 @@ pub async fn edit_pat(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
/// Delete a personal access token.
|
||||
#[utoipa::path(
|
||||
tag = "personal access tokens",
|
||||
delete,
|
||||
operation_id = "deletePat",
|
||||
params(("id" = String, Path, description = "The PAT ID")),
|
||||
params(
|
||||
("id" = String, Path, description = "The PAT ID")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "PAT deleted"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
|
||||
@@ -5,20 +5,30 @@ use crate::{
|
||||
};
|
||||
use actix_web::{delete, get, web};
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(tasks).service(tasks_cancel);
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[get("tasks", guard = "admin_key_guard")]
|
||||
/// List search tasks.
|
||||
#[utoipa::path(
|
||||
context_path = "/search-management",
|
||||
tag = "search",
|
||||
responses((status = OK, body = serde_json::Value))
|
||||
)]
|
||||
#[get("/tasks", guard = "admin_key_guard")]
|
||||
pub async fn tasks(
|
||||
search: web::Data<dyn SearchBackend>,
|
||||
) -> Result<web::Json<serde_json::Value>, ApiError> {
|
||||
Ok(web::Json(search.tasks().await.map_err(ApiError::Internal)?))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[delete("tasks", guard = "admin_key_guard")]
|
||||
/// Cancel search tasks.
|
||||
#[utoipa::path(
|
||||
context_path = "/search-management",
|
||||
tag = "search",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[delete("/tasks", guard = "admin_key_guard")]
|
||||
pub async fn tasks_cancel(
|
||||
search: web::Data<dyn SearchBackend>,
|
||||
body: web::Json<TasksCancelFilter>,
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
util::error::Context,
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(ping_minecraft_java);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,12 @@ pub struct PingRequest {
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
/// Ping Minecraft server.
|
||||
#[utoipa::path(
|
||||
context_path = "/server-ping",
|
||||
tag = "server ping",
|
||||
responses((status = NO_CONTENT))
|
||||
)]
|
||||
#[post("/minecraft-java")]
|
||||
pub async fn ping_minecraft_java(
|
||||
req: HttpRequest,
|
||||
|
||||
@@ -19,9 +19,9 @@ use rand::{Rng, SeedableRng};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
use woothee::parser::Parser;
|
||||
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(
|
||||
utoipa_actix_web::scope("/session")
|
||||
web::scope("/session")
|
||||
.service(list)
|
||||
.service(delete)
|
||||
.service(refresh),
|
||||
@@ -133,13 +133,16 @@ pub async fn issue_session(
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// List sessions.
|
||||
#[utoipa::path(
|
||||
context_path = "/session",
|
||||
tag = "sessions",
|
||||
get,
|
||||
operation_id = "listSessions",
|
||||
responses(
|
||||
(status = 200, description = "List of active sessions"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
operation_id = "listSessions",
|
||||
responses(
|
||||
(status = 200, description = "List of active sessions", body = serde_json::Value),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
security(("bearer_auth" = ["SESSION_READ"]))
|
||||
)]
|
||||
#[get("/list")]
|
||||
@@ -178,10 +181,15 @@ pub async fn list(
|
||||
Ok(HttpResponse::Ok().json(sessions))
|
||||
}
|
||||
|
||||
/// Delete a session.
|
||||
#[utoipa::path(
|
||||
context_path = "/session",
|
||||
tag = "sessions",
|
||||
delete,
|
||||
operation_id = "deleteSession",
|
||||
params(("id" = String, Path, description = "The session ID")),
|
||||
params(
|
||||
("id" = String, Path, description = "The session ID")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Session deleted"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
@@ -228,13 +236,16 @@ pub async fn delete(
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
/// Refresh a session.
|
||||
#[utoipa::path(
|
||||
context_path = "/session",
|
||||
tag = "sessions",
|
||||
post,
|
||||
operation_id = "refreshSession",
|
||||
responses(
|
||||
(status = 200, description = "Session refreshed"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
operation_id = "refreshSession",
|
||||
responses(
|
||||
(status = 200, description = "Session refreshed", body = serde_json::Value),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
#[post("/refresh")]
|
||||
pub async fn refresh(
|
||||
|
||||
@@ -35,7 +35,7 @@ use std::sync::atomic::Ordering;
|
||||
use tokio::sync::oneshot::error::TryRecvError;
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(ws_init);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,12 @@ struct LauncherHeartbeatInit {
|
||||
}
|
||||
|
||||
// TODO: Move launcher-specific tunnel traffic to a proper launcher websocket endpoint.
|
||||
#[get("launcher_socket")]
|
||||
/// Start launcher socket.
|
||||
#[utoipa::path(
|
||||
tag = "statuses",
|
||||
responses((status = 101))
|
||||
)]
|
||||
#[get("/launcher_socket")]
|
||||
pub async fn ws_init(
|
||||
req: HttpRequest,
|
||||
pool: Data<PgPool>,
|
||||
|
||||
@@ -18,7 +18,7 @@ use quick_xml::escape::escape;
|
||||
use std::collections::HashSet;
|
||||
use yaserde::YaSerialize;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(maven_metadata);
|
||||
cfg.service(version_file_sha512);
|
||||
cfg.service(version_file_sha1);
|
||||
@@ -70,7 +70,12 @@ pub struct MavenPom {
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[get("maven/modrinth/{id}/maven-metadata.xml")]
|
||||
#[utoipa::path(
|
||||
tag = "maven",
|
||||
params(("id" = String, Path)),
|
||||
responses((status = OK, body = String, content_type = "text/xml"))
|
||||
)]
|
||||
#[get("/maven/modrinth/{id}/maven-metadata.xml")]
|
||||
pub async fn maven_metadata(
|
||||
req: HttpRequest,
|
||||
params: web::Path<(String,)>,
|
||||
@@ -279,8 +284,20 @@ fn find_file<'a>(
|
||||
None
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
tag = "maven",
|
||||
params(
|
||||
("id" = String, Path),
|
||||
("versionnum" = String, Path),
|
||||
("file" = String, Path)
|
||||
),
|
||||
responses(
|
||||
(status = OK, body = String, content_type = "text/xml"),
|
||||
(status = TEMPORARY_REDIRECT)
|
||||
)
|
||||
)]
|
||||
#[route(
|
||||
"maven/modrinth/{id}/{versionnum}/{file}",
|
||||
"/maven/modrinth/{id}/{versionnum}/{file}",
|
||||
method = "GET",
|
||||
method = "HEAD"
|
||||
)]
|
||||
@@ -349,7 +366,16 @@ pub async fn version_file(
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
|
||||
#[get("maven/modrinth/{id}/{versionnum}/{file}.sha1")]
|
||||
#[utoipa::path(
|
||||
tag = "maven",
|
||||
params(
|
||||
("id" = String, Path),
|
||||
("versionnum" = String, Path),
|
||||
("file" = String, Path)
|
||||
),
|
||||
responses((status = OK, body = String))
|
||||
)]
|
||||
#[get("/maven/modrinth/{id}/{versionnum}/{file}.sha1")]
|
||||
pub async fn version_file_sha1(
|
||||
req: HttpRequest,
|
||||
params: web::Path<(String, String, String)>,
|
||||
@@ -396,7 +422,16 @@ pub async fn version_file_sha1(
|
||||
))
|
||||
}
|
||||
|
||||
#[get("maven/modrinth/{id}/{versionnum}/{file}.sha512")]
|
||||
#[utoipa::path(
|
||||
tag = "maven",
|
||||
params(
|
||||
("id" = String, Path),
|
||||
("versionnum" = String, Path),
|
||||
("file" = String, Path)
|
||||
),
|
||||
responses((status = OK, body = String))
|
||||
)]
|
||||
#[get("/maven/modrinth/{id}/{versionnum}/{file}.sha512")]
|
||||
pub async fn version_file_sha512(
|
||||
req: HttpRequest,
|
||||
params: web::Path<(String, String, String)>,
|
||||
|
||||
+119
-19
@@ -8,6 +8,8 @@ use actix_web::http::StatusCode;
|
||||
use actix_web::{HttpResponse, web};
|
||||
use futures::FutureExt;
|
||||
use serde_json::json;
|
||||
use utoipa::openapi::extensions::ExtensionsBuilder;
|
||||
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
|
||||
|
||||
pub mod debug;
|
||||
pub mod internal;
|
||||
@@ -23,7 +25,124 @@ mod updates;
|
||||
|
||||
pub use self::not_found::not_found;
|
||||
|
||||
// utoipa-specific struct to use a value_type for docs.
|
||||
/// A sha1 or sha512 hash.
|
||||
pub struct FileHash;
|
||||
|
||||
impl utoipa::PartialSchema for FileHash {
|
||||
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
|
||||
utoipa::openapi::ObjectBuilder::new()
|
||||
.schema_type(utoipa::openapi::schema::Type::String)
|
||||
.min_length(Some(40))
|
||||
.max_length(Some(128))
|
||||
.pattern(Some("^([A-Fa-f0-9]{40}|[A-Fa-f0-9]{128})$"))
|
||||
.examples([serde_json::json!(
|
||||
"2aae6c35c94fcfb415dbe95f408b9ce91ee846ed"
|
||||
)])
|
||||
.description(Some("A sha1 or sha512 hash."))
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl utoipa::ToSchema for FileHash {}
|
||||
|
||||
// utoipa-specific struct to use a value_type for docs.
|
||||
/// A hashing algorithm (sha1 or sha256)
|
||||
pub struct HashAlgorithm;
|
||||
|
||||
impl utoipa::PartialSchema for HashAlgorithm {
|
||||
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
|
||||
utoipa::openapi::ObjectBuilder::new()
|
||||
.enum_values(Some([
|
||||
serde_json::json!("sha1"),
|
||||
serde_json::json!("sha512"),
|
||||
]))
|
||||
.examples([serde_json::json!("sha1")])
|
||||
.description(Some("A supported hashing algorithm."))
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl utoipa::ToSchema for HashAlgorithm {}
|
||||
|
||||
pub(crate) fn prefix_openapi_paths(
|
||||
openapi: &mut utoipa::openapi::OpenApi,
|
||||
prefix: &str,
|
||||
should_skip: impl Fn(&str) -> bool,
|
||||
) {
|
||||
let paths = std::mem::take(&mut openapi.paths.paths);
|
||||
openapi.paths.paths = paths
|
||||
.into_iter()
|
||||
.map(|(path, item)| {
|
||||
if should_skip(&path) || path.starts_with(prefix) {
|
||||
(path, item)
|
||||
} else {
|
||||
(format!("{prefix}{}", normalize_openapi_path(&path)), item)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn normalize_openapi_path(path: &str) -> String {
|
||||
if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct SecurityAddon;
|
||||
|
||||
impl utoipa::Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
let components = openapi.components.as_mut().unwrap();
|
||||
let mut bearer_auth = HttpBuilder::new()
|
||||
.scheme(HttpAuthScheme::Bearer)
|
||||
.description(Some(
|
||||
"Use a personal access token. Example: `mrp_RNtLRSPmGj2pd1v1ubi52nX7TJJM9sznrmwhAuj511oe4t1jAqAQ3D6Wc8Ic`.",
|
||||
))
|
||||
.build();
|
||||
bearer_auth.extensions = Some(
|
||||
ExtensionsBuilder::new()
|
||||
.add(
|
||||
"x-example",
|
||||
"mrp_RNtLRSPmGj2pd1v1ubi52nX7TJJM9sznrmwhAuj511oe4t1jAqAQ3D6Wc8Ic",
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
|
||||
components.add_security_scheme(
|
||||
"bearer_auth",
|
||||
SecurityScheme::Http(bearer_auth),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root_config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/api/v1")
|
||||
.wrap(default_cors())
|
||||
.wrap_fn(|req, _srv| {
|
||||
async {
|
||||
Ok(req.into_response(
|
||||
HttpResponse::Gone()
|
||||
.content_type("application/json")
|
||||
.body(r#"{"error":"api_deprecated","description":"You are using an application that uses an outdated version of Modrinth's API. Please either update it or switch to another application. For developers: https://docs.modrinth.com/api/#versioning"}"#)
|
||||
))
|
||||
}.boxed_local()
|
||||
})
|
||||
);
|
||||
cfg.service(
|
||||
web::scope("")
|
||||
.wrap(default_cors())
|
||||
.service(index::index_get)
|
||||
.service(Files::new("/", "assets/")),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn public_config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/maven")
|
||||
.wrap(default_cors())
|
||||
@@ -58,25 +177,6 @@ pub fn root_config(cfg: &mut web::ServiceConfig) {
|
||||
)
|
||||
.configure(analytics::config),
|
||||
);
|
||||
cfg.service(
|
||||
web::scope("/api/v1")
|
||||
.wrap(default_cors())
|
||||
.wrap_fn(|req, _srv| {
|
||||
async {
|
||||
Ok(req.into_response(
|
||||
HttpResponse::Gone()
|
||||
.content_type("application/json")
|
||||
.body(r#"{"error":"api_deprecated","description":"You are using an application that uses an outdated version of Modrinth's API. Please either update it or switch to another application. For developers: https://docs.modrinth.com/api/#versioning"}"#)
|
||||
))
|
||||
}.boxed_local()
|
||||
})
|
||||
);
|
||||
cfg.service(
|
||||
web::scope("")
|
||||
.wrap(default_cors())
|
||||
.service(index::index_get)
|
||||
.service(Files::new("/", "assets/")),
|
||||
);
|
||||
}
|
||||
|
||||
/// Error when calling an HTTP endpoint.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user