diff --git a/.github/workflows/labrinth-docker.yml b/.github/workflows/labrinth-docker.yml
index 6d3686595e..9f285197a2 100644
--- a/.github/workflows/labrinth-docker.yml
+++ b/.github/workflows/labrinth-docker.yml
@@ -7,11 +7,13 @@ on:
paths:
- .github/workflows/labrinth-docker.yml
- 'apps/labrinth/**'
+ - 'packages/ariadne/**'
pull_request:
types: [opened, synchronize]
paths:
- .github/workflows/labrinth-docker.yml
- 'apps/labrinth/**'
+ - 'packages/ariadne/**'
merge_group:
types: [checks_requested]
diff --git a/.github/workflows/turbo-ci.yml b/.github/workflows/turbo-ci.yml
index 0616743fba..e4038c7579 100644
--- a/.github/workflows/turbo-ci.yml
+++ b/.github/workflows/turbo-ci.yml
@@ -84,4 +84,6 @@ jobs:
- name: 🔍 Verify intl:extract has been run
run: |
pnpm intl:extract
- git diff --exit-code --color */*/src/locales/en-US/index.json
+ git diff --exit-code --color \
+ */*/src/locales/en-US/index.json \
+ apps/frontend/src/locales/en-US/labrinth.json
diff --git a/.idea/code.iml b/.idea/code.iml
index 4c7179f5df..acbaa11e5d 100644
--- a/.idea/code.iml
+++ b/.idea/code.iml
@@ -11,6 +11,7 @@
+
diff --git a/Cargo.lock b/Cargo.lock
index 68f3dcbd66..2b48012d5e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -103,7 +103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb"
dependencies = [
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -141,7 +141,7 @@ dependencies = [
"parse-size",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -259,7 +259,7 @@ dependencies = [
"actix-router",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -457,7 +457,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -487,6 +487,22 @@ dependencies = [
"uuid 1.17.0",
]
+[[package]]
+name = "ariadne-extract"
+version = "0.1.0"
+dependencies = [
+ "clap",
+ "derive_more 2.0.1",
+ "miette",
+ "proc-macro2",
+ "serde",
+ "serde_json",
+ "syn 2.0.106",
+ "syn-miette",
+ "thiserror 2.0.12",
+ "walkdir",
+]
+
[[package]]
name = "arrayvec"
version = "0.7.6"
@@ -654,7 +670,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -694,7 +710,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -735,7 +751,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -955,6 +971,15 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "backtrace-ext"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50"
+dependencies = [
+ "backtrace",
+]
+
[[package]]
name = "base16ct"
version = "0.2.0"
@@ -991,6 +1016,15 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "bit-set"
version = "0.5.3"
@@ -1114,7 +1148,7 @@ dependencies = [
"proc-macro-crate 3.3.0",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -1425,7 +1459,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -1470,7 +1504,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -1840,7 +1874,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -1871,7 +1905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
dependencies = [
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -1931,7 +1965,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -1942,7 +1976,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -2047,7 +2081,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -2068,7 +2102,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -2078,7 +2112,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -2091,7 +2125,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustc_version",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -2111,7 +2145,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
"unicode-xid",
]
@@ -2208,7 +2242,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -2240,7 +2274,7 @@ checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -2424,7 +2458,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -2445,7 +2479,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -2467,7 +2501,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -2512,7 +2546,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -2725,7 +2759,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -2880,7 +2914,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -3176,7 +3210,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -3266,7 +3300,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -4015,7 +4049,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -4093,6 +4127,12 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "is_ci"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@@ -4356,6 +4396,7 @@ dependencies = [
"console-subscriber",
"dashmap",
"deadpool-redis",
+ "derive_more 2.0.1",
"dotenv-build",
"dotenvy",
"either",
@@ -4389,6 +4430,7 @@ dependencies = [
"sentry",
"sentry-actix",
"serde",
+ "serde-xml-rs",
"serde_json",
"serde_with",
"sha1",
@@ -4410,7 +4452,6 @@ dependencies = [
"validator",
"webp",
"woothee",
- "yaserde",
"zip",
"zxcvbn",
]
@@ -4578,6 +4619,12 @@ dependencies = [
"zlib-rs",
]
+[[package]]
+name = "linked-hash-map"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
+
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -4710,7 +4757,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -4755,7 +4802,7 @@ checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -4794,7 +4841,7 @@ dependencies = [
"proc-macro2",
"quote",
"structmeta",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -4839,6 +4886,37 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "miette"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
+dependencies = [
+ "backtrace",
+ "backtrace-ext",
+ "cfg-if",
+ "miette-derive",
+ "owo-colors",
+ "supports-color",
+ "supports-hyperlinks",
+ "supports-unicode",
+ "syntect",
+ "terminal_size",
+ "textwrap",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "miette-derive"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.106",
+]
+
[[package]]
name = "mime"
version = "0.3.17"
@@ -5173,7 +5251,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -5256,7 +5334,7 @@ dependencies = [
"proc-macro-crate 3.3.0",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -5536,6 +5614,28 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
+[[package]]
+name = "onig"
+version = "6.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
+dependencies = [
+ "bitflags 2.9.1",
+ "libc",
+ "once_cell",
+ "onig_sys",
+]
+
+[[package]]
+name = "onig_sys"
+version = "69.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
[[package]]
name = "open"
version = "5.3.2"
@@ -5612,6 +5712,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
+[[package]]
+name = "owo-colors"
+version = "4.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e"
+
[[package]]
name = "p256"
version = "0.13.2"
@@ -5847,7 +5953,7 @@ dependencies = [
"phf_shared 0.11.3",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -5860,7 +5966,7 @@ dependencies = [
"phf_shared 0.12.1",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -5916,7 +6022,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -6154,7 +6260,7 @@ dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -6165,9 +6271,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
[[package]]
name = "proc-macro2"
-version = "1.0.95"
+version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
+checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
dependencies = [
"unicode-ident",
]
@@ -6210,7 +6316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
dependencies = [
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -6250,7 +6356,7 @@ dependencies = [
"itertools 0.14.0",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -6747,7 +6853,7 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -7055,7 +7161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6268b74858287e1a062271b988a0c534bf85bbeb567fe09331bf40ed78113d5"
dependencies = [
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -7331,7 +7437,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -7370,7 +7476,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -7629,7 +7735,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -7640,7 +7746,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -7715,7 +7821,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -7736,18 +7842,6 @@ dependencies = [
"serde",
]
-[[package]]
-name = "serde_tokenstream"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1"
-dependencies = [
- "proc-macro2",
- "quote",
- "serde",
- "syn 2.0.104",
-]
-
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -7789,7 +7883,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -8123,7 +8217,7 @@ dependencies = [
"quote",
"sqlx-core",
"sqlx-macros-core",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -8146,7 +8240,7 @@ dependencies = [
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
- "syn 2.0.104",
+ "syn 2.0.106",
"tokio",
"url",
]
@@ -8350,7 +8444,7 @@ dependencies = [
"proc-macro2",
"quote",
"structmeta-derive",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -8361,7 +8455,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -8370,6 +8464,27 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+[[package]]
+name = "supports-color"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
+dependencies = [
+ "is_ci",
+]
+
+[[package]]
+name = "supports-hyperlinks"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b"
+
+[[package]]
+name = "supports-unicode"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
+
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -8394,15 +8509,26 @@ dependencies = [
[[package]]
name = "syn"
-version = "2.0.104"
+version = "2.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
+checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
+[[package]]
+name = "syn-miette"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd1a2bfae2df81406f8d21baa0253d34ddd0ddafcd1e7aa12aa24279bb76a24b"
+dependencies = [
+ "miette",
+ "proc-macro2",
+ "syn 2.0.106",
+]
+
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -8420,7 +8546,29 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
+]
+
+[[package]]
+name = "syntect"
+version = "5.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1"
+dependencies = [
+ "bincode",
+ "bitflags 1.3.2",
+ "flate2",
+ "fnv",
+ "once_cell",
+ "onig",
+ "plist",
+ "regex-syntax 0.8.5",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "thiserror 1.0.69",
+ "walkdir",
+ "yaml-rust",
]
[[package]]
@@ -8533,7 +8681,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -8652,7 +8800,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
- "syn 2.0.104",
+ "syn 2.0.106",
"tauri-utils",
"thiserror 2.0.12",
"time",
@@ -8670,7 +8818,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
"tauri-codegen",
"tauri-utils",
]
@@ -9011,6 +9159,26 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "terminal_size"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0"
+dependencies = [
+ "rustix 1.0.8",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
+dependencies = [
+ "unicode-linebreak",
+ "unicode-width 0.2.1",
+]
+
[[package]]
name = "theseus"
version = "1.0.0-local"
@@ -9150,7 +9318,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -9161,7 +9329,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -9309,7 +9477,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -9601,7 +9769,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -9792,6 +9960,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+[[package]]
+name = "unicode-linebreak"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+
[[package]]
name = "unicode-normalization"
version = "0.1.24"
@@ -9986,7 +10160,7 @@ dependencies = [
"proc-macro-error2",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -10123,7 +10297,7 @@ dependencies = [
"log",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
"wasm-bindgen-shared",
]
@@ -10158,7 +10332,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@@ -10358,7 +10532,7 @@ checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -10517,7 +10691,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -10528,7 +10702,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -11041,30 +11215,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7"
[[package]]
-name = "yaserde"
-version = "0.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8bfa0d2b420fd005aa9b6f99f9584ebd964e6865d7ca787304cc1a3366c39231"
-dependencies = [
- "log",
- "xml-rs",
- "yaserde_derive",
-]
-
-[[package]]
-name = "yaserde_derive"
-version = "0.12.0"
+name = "yaml-rust"
+version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1f785831c0e09e0f1a83f917054fd59c088f6561db5b2a42c1c3e1687329325f"
+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
dependencies = [
- "heck 0.5.0",
- "log",
- "proc-macro2",
- "quote",
- "serde",
- "serde_tokenstream",
- "syn 2.0.104",
- "xml-rs",
+ "linked-hash-map",
]
[[package]]
@@ -11098,7 +11254,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
"synstructure",
]
@@ -11145,7 +11301,7 @@ dependencies = [
"proc-macro-crate 3.3.0",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
"zbus_names",
"zvariant",
"zvariant_utils",
@@ -11180,7 +11336,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -11200,7 +11356,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
"synstructure",
]
@@ -11240,7 +11396,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
]
[[package]]
@@ -11354,7 +11510,7 @@ dependencies = [
"proc-macro-crate 3.3.0",
"proc-macro2",
"quote",
- "syn 2.0.104",
+ "syn 2.0.106",
"zvariant_utils",
]
@@ -11368,7 +11524,7 @@ dependencies = [
"quote",
"serde",
"static_assertions",
- "syn 2.0.104",
+ "syn 2.0.106",
"winnow 0.7.12",
]
diff --git a/Cargo.toml b/Cargo.toml
index 5f0a461f58..05e7a6e769 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,6 +3,7 @@ resolver = "2"
members = [
"apps/app",
"apps/app-playground",
+ "apps/ariadne-extract",
"apps/daedalus_client",
"apps/labrinth",
"packages/app-lib",
@@ -40,7 +41,7 @@ bytes = "1.10.1"
censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.41"
-clap = "4.5.43"
+clap = { version = "4.5.43", features = ["derive"] }
clickhouse = "0.13.3"
color-thief = "0.2.2"
console-subscriber = "0.4.1"
@@ -93,6 +94,7 @@ lettre = { version = "0.11.18", default-features = false, features = [
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.29.1", default-features = false }
+miette = "7.6.0"
murmur2 = "0.1.0"
native-dialog = "0.9.0"
notify = { version = "8.2.0", default-features = false }
@@ -101,6 +103,7 @@ p256 = "0.13.2"
paste = "1.0.15"
phf = { version = "0.12.1", features = ["macros"] }
png = "0.17.16"
+proc-macro2 = "1.0.101"
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
quick-xml = "0.38.1"
@@ -127,18 +130,20 @@ sentry = { version = "0.42.0", default-features = false, features = [
"rustls",
] }
sentry-actix = "0.42.0"
-serde = "1.0.219"
+serde = { version = "1.0.219", features = ["derive"] }
serde_bytes = "0.11.17"
serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.142"
serde_with = "3.14.0"
-serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
+serde-xml-rs = "0.8.1"
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
spdx = "0.10.9"
sqlx = { version = "0.8.6", default-features = false }
+syn = { version = "2.0.106", features = ["full"] }
+syn-miette = "0.3.0"
sysinfo = { version = "0.36.1", default-features = false }
tar = "0.4.44"
tauri = "2.7.0"
@@ -171,11 +176,11 @@ url = "2.5.4"
urlencoding = "2.1.3"
uuid = "1.17.0"
validator = "0.20.0"
+walkdir = "2.5.0"
webp = { version = "0.3.0", default-features = false }
whoami = "1.6.0"
winreg = "0.55.0"
woothee = "0.13.0"
-yaserde = "0.12.0"
zip = { version = "4.3.0", default-features = false, features = [
"bzip2",
"deflate",
diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml
index b0a489bd0e..745c20e210 100644
--- a/apps/app/Cargo.toml
+++ b/apps/app/Cargo.toml
@@ -13,7 +13,7 @@ tauri-build = { workspace = true, features = ["codegen"] }
theseus = { workspace = true, features = ["tauri"] }
serde_json.workspace = true
-serde = { workspace = true, features = ["derive"] }
+serde.workspace = true
serde_with.workspace = true
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] }
diff --git a/apps/ariadne-extract/.prettierignore b/apps/ariadne-extract/.prettierignore
new file mode 100644
index 0000000000..ecc487ec4f
--- /dev/null
+++ b/apps/ariadne-extract/.prettierignore
@@ -0,0 +1 @@
+**/*.rs
diff --git a/apps/ariadne-extract/Cargo.toml b/apps/ariadne-extract/Cargo.toml
new file mode 100644
index 0000000000..ddc5dfe6fd
--- /dev/null
+++ b/apps/ariadne-extract/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "ariadne-extract"
+version = "0.1.0"
+edition.workspace = true
+
+[dependencies]
+clap.workspace = true
+thiserror.workspace = true
+derive_more = { workspace = true, features = ["display"] }
+
+serde_json.workspace = true
+serde.workspace = true
+
+syn = { workspace = true, features = ["visit"] }
+proc-macro2.workspace = true
+walkdir.workspace = true
+
+syn-miette.workspace = true
+miette = { workspace = true, features = ["syntect-highlighter"] }
+
+[lints]
+workspace = true
diff --git a/apps/ariadne-extract/package.json b/apps/ariadne-extract/package.json
new file mode 100644
index 0000000000..5a1bb6ed94
--- /dev/null
+++ b/apps/ariadne-extract/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@modrinth/ariadne-extract",
+ "scripts": {
+ "lint": "cargo fmt --check && cargo clippy --all-targets",
+ "lint:ancillary": "prettier --check .",
+ "fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt",
+ "fix:ancillary": "prettier --write .",
+ "test": "cargo nextest run --all-targets --no-fail-fast"
+ },
+ "prettier": "@modrinth/tooling-config/app-lib.prettier.config.cjs"
+}
diff --git a/apps/ariadne-extract/src/error.rs b/apps/ariadne-extract/src/error.rs
new file mode 100644
index 0000000000..13b9e3c9fc
--- /dev/null
+++ b/apps/ariadne-extract/src/error.rs
@@ -0,0 +1,14 @@
+use std::io;
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum ExtractError {
+ #[error("I/O Error: {0}")]
+ Io(#[from] io::Error),
+ #[error("Serialization: {0}")]
+ SerDe(#[from] serde_json::Error),
+ #[error("Walking directory: {0}")]
+ WalkDir(#[from] walkdir::Error),
+}
+
+pub type Result = std::result::Result;
diff --git a/apps/ariadne-extract/src/extractor.rs b/apps/ariadne-extract/src/extractor.rs
new file mode 100644
index 0000000000..8dc918f14c
--- /dev/null
+++ b/apps/ariadne-extract/src/extractor.rs
@@ -0,0 +1,658 @@
+use crate::error::Result;
+use derive_more::Display;
+use proc_macro2::Span;
+use serde::Serialize;
+use std::collections::btree_map::Entry;
+use std::collections::{BTreeMap, HashMap, HashSet};
+use std::fs;
+use std::path::Path;
+use syn::parse::{Parse, ParseStream};
+use syn::punctuated::Punctuated;
+use syn::spanned::Spanned;
+use syn::visit::Visit;
+use syn::{
+ Attribute, File, Ident, ItemEnum, ItemMod, LitStr, Macro, Meta, Token,
+ Type, braced, bracketed, parenthesized, token, visit,
+};
+use walkdir::{DirEntry, WalkDir};
+
+#[derive(Debug, Serialize)]
+pub struct TranslationEntry {
+ pub message: String,
+ #[serde(skip)]
+ key_span: Span,
+}
+
+impl TranslationEntry {
+ pub fn new(message: String, key_span: Span) -> Self {
+ Self { message, key_span }
+ }
+}
+
+pub struct Extractor {
+ include_tests: bool,
+ output: BTreeMap,
+ errors: Vec,
+}
+
+impl Extractor {
+ pub fn new(include_tests: bool) -> Self {
+ Self {
+ output: BTreeMap::new(),
+ include_tests,
+ errors: vec![],
+ }
+ }
+
+ pub fn output(&self) -> &BTreeMap {
+ &self.output
+ }
+
+ pub fn errors(&self) -> &Vec {
+ &self.errors
+ }
+}
+
+impl Extractor {
+ pub fn process_package(&mut self, package_path: &Path) -> Result<()> {
+ for file in WalkDir::new(package_path.join("src")).min_depth(1) {
+ let result = self.process_file(file);
+ result?;
+ }
+ Ok(())
+ }
+
+ fn process_file(&mut self, file: walkdir::Result) -> Result<()> {
+ let file = file?;
+ if !file.file_type().is_file()
+ || file.path().extension().is_none_or(|x| x != "rs")
+ {
+ return Ok(());
+ }
+ let file_contents = fs::read_to_string(file.path())?;
+ let mut file_extractor = FileExtractor {
+ extractor: self,
+ file: FileInfo {
+ path: file.path(),
+ source: &file_contents,
+ },
+ enum_messages: HashMap::new(),
+ };
+ let parsed = match syn::parse_file(&file_contents) {
+ Ok(file) => file,
+ Err(err) => {
+ file_extractor.add_error(err);
+ return Ok(());
+ }
+ };
+ file_extractor.visit_file(&parsed);
+ Ok(())
+ }
+
+ fn include_item(&self, attrs: &[Attribute]) -> bool {
+ fn include_from_meta(meta: &Meta, include_tests: bool) -> bool {
+ if meta.path().is_ident("test") {
+ include_tests
+ } else if meta.path().is_ident("not") {
+ let Ok(list) = meta.require_list() else {
+ return true;
+ };
+ let Ok(inner) = list.parse_args() else {
+ return true;
+ };
+ !include_from_meta(&inner, include_tests)
+ } else if meta.path().is_ident("all") || meta.path().is_ident("any")
+ {
+ let Ok(list) = meta.require_list() else {
+ return true;
+ };
+ let Ok(nested) = list.parse_args_with(
+ Punctuated::::parse_terminated,
+ ) else {
+ return true;
+ };
+ if meta.path().is_ident("all") {
+ nested.iter().all(|x| include_from_meta(x, include_tests))
+ } else {
+ nested.iter().any(|x| include_from_meta(x, include_tests))
+ }
+ } else {
+ true
+ }
+ }
+ fn include_from_attr(attr: &Attribute, include_tests: bool) -> bool {
+ if !attr.path().is_ident("cfg") {
+ return true;
+ }
+ let Ok(inner) = attr.parse_args() else {
+ return true;
+ };
+ include_from_meta(&inner, include_tests)
+ }
+ attrs
+ .iter()
+ .all(|x| include_from_attr(x, self.include_tests))
+ }
+
+ fn add_error(&mut self, file: &FileInfo, error: syn::Error) {
+ self.errors.push(syn_miette::Error::new_named(
+ error,
+ file.source,
+ file.path.display(),
+ ));
+ }
+}
+
+struct FileExtractor<'a> {
+ extractor: &'a mut Extractor,
+ file: FileInfo<'a>,
+ enum_messages: HashMap>,
+}
+
+struct FileInfo<'a> {
+ path: &'a Path,
+ source: &'a str,
+}
+
+#[derive(Clone)]
+enum EnumMessage {
+ Absent {
+ variant_span: Span,
+ },
+ Present {
+ message: LitStr,
+ display_attribute_type: DisplayAttributeType,
+ },
+}
+
+impl EnumMessage {
+ fn as_option(&self) -> Option<(&LitStr, DisplayAttributeType)> {
+ match self {
+ Self::Absent { .. } => None,
+ Self::Present {
+ message,
+ display_attribute_type,
+ } => Some((message, *display_attribute_type)),
+ }
+ }
+}
+
+#[derive(PartialEq, Eq, Copy, Clone, Display)]
+enum DisplayAttributeType {
+ #[display("#[error]")]
+ ThiserrorError,
+ #[display("#[display]")]
+ DeriveMoreDisplay,
+}
+
+impl FileExtractor<'_> {
+ fn add_error(&mut self, error: syn::Error) {
+ self.extractor.add_error(&self.file, error);
+ }
+}
+
+impl Visit<'_> for FileExtractor<'_> {
+ fn visit_file(&mut self, i: &File) {
+ if self.extractor.include_item(&i.attrs) {
+ visit::visit_file(self, i);
+ }
+ }
+
+ fn visit_item_enum(&mut self, i: &ItemEnum) {
+ let mut variants = HashMap::new();
+ let mut has_missing_variants = false;
+ let mut ignored_variants = HashSet::new();
+ let mut main_display_attribute_type = None;
+
+ for variant in &i.variants {
+ let Some(error_attr) = variant.attrs.iter().find(|x| {
+ x.path().is_ident("error") || x.path().is_ident("display")
+ }) else {
+ has_missing_variants = true;
+ continue;
+ };
+ let display_attribute_type = if error_attr.path().is_ident("error")
+ {
+ DisplayAttributeType::ThiserrorError
+ } else {
+ DisplayAttributeType::DeriveMoreDisplay
+ };
+ if display_attribute_type
+ != main_display_attribute_type
+ .get_or_insert_with(|| {
+ (error_attr.path().span(), display_attribute_type)
+ })
+ .1
+ {
+ let (old_span, old_type) = main_display_attribute_type.unwrap();
+ let mut error = syn::Error::new(
+ error_attr.path().span(),
+ format!(
+ "inconsistent usage of {old_type} and {display_attribute_type}"
+ ),
+ );
+ error.combine(syn::Error::new(
+ old_span,
+ format!("{old_type} initially used here"),
+ ));
+ self.add_error(error);
+ }
+ let error_message = error_attr
+ .parse_args_with(|input: ParseStream| {
+ if display_attribute_type
+ == DisplayAttributeType::ThiserrorError
+ && input.peek(kw::transparent)
+ {
+ input.parse::()?;
+ Ok(None)
+ } else {
+ Ok(Some(input.parse::()?))
+ }
+ })
+ .transpose();
+ match error_message {
+ Some(Ok(message)) => {
+ variants.insert(
+ variant.ident.clone(),
+ EnumMessage::Present {
+ message,
+ display_attribute_type,
+ },
+ );
+ }
+ Some(Err(_)) => {
+ self.add_error(syn::Error::new(
+ error_attr.meta.span(),
+ match display_attribute_type {
+ DisplayAttributeType::ThiserrorError => {
+ "only #[error(transparent)] and #[error(\"lone format string\")] syntaxes are supported"
+ }
+ DisplayAttributeType::DeriveMoreDisplay => {
+ "only #[display(\"lone format string\")] syntax is supported"
+ }
+ },
+ ));
+ ignored_variants.insert(variant.ident.clone());
+ }
+ None => {
+ ignored_variants.insert(variant.ident.clone());
+ }
+ }
+ }
+ if !variants.is_empty() {
+ if has_missing_variants {
+ for variant in &i.variants {
+ if ignored_variants.contains(&variant.ident) {
+ continue;
+ }
+ variants.entry(variant.ident.clone()).or_insert_with(
+ || match main_display_attribute_type.unwrap().1 {
+ DisplayAttributeType::ThiserrorError => {
+ EnumMessage::Absent {
+ variant_span: variant.span(),
+ }
+ }
+ DisplayAttributeType::DeriveMoreDisplay => {
+ EnumMessage::Present {
+ message: LitStr::new(
+ &variant.ident.to_string(),
+ variant.ident.span(),
+ ),
+ display_attribute_type:
+ DisplayAttributeType::DeriveMoreDisplay,
+ }
+ }
+ },
+ );
+ }
+ }
+ self.enum_messages.insert(i.ident.clone(), variants);
+ }
+ }
+
+ fn visit_item_mod(&mut self, i: &ItemMod) {
+ if self.extractor.include_item(&i.attrs) {
+ visit::visit_item_mod(self, i);
+ }
+ }
+
+ fn visit_macro(&mut self, i: &Macro) {
+ if !i.path.is_ident("i18n_enum") {
+ return;
+ }
+ let body = match i.parse_body() {
+ Ok(body) => body,
+ Err(err) => {
+ self.add_error(err);
+ return;
+ }
+ };
+ match body {
+ I18nEnumMacroInvocation::Transparent => {}
+ I18nEnumMacroInvocation::Enum {
+ for_enum,
+ root_key,
+ variants,
+ } => {
+ let Some(messages) = self.enum_messages.get(&for_enum) else {
+ return;
+ };
+ let root_key = root_key.value();
+ for variant in variants {
+ if variant.transparent.is_some() {
+ continue;
+ }
+ let Some(message_literal) =
+ messages.get(&variant.variant_name)
+ else {
+ continue;
+ };
+ let (message, errors) = message_literal
+ .as_option()
+ .map_or_else(
+ || ("".into(), vec![format!("no default message specified for variant {}", variant.variant_name)]),
+ |(message, display_attribute_type)| {
+ variant.transform_format_string(
+ message.value(),
+ display_attribute_type,
+ )
+ },
+ );
+ let duplicate_key = match self
+ .extractor
+ .output
+ .entry(format!("{}.{}", root_key, variant.key.value()))
+ {
+ Entry::Vacant(entry) => {
+ entry.insert(TranslationEntry::new(
+ message,
+ variant.key.span(),
+ ));
+ None
+ }
+ Entry::Occupied(entry) => {
+ (*entry.get().message != message).then(|| {
+ (entry.key().clone(), entry.get().key_span)
+ })
+ }
+ };
+ if let Some((duplicate_key, original_span)) = duplicate_key
+ {
+ let mut error = syn::Error::new(
+ variant.key.span(),
+ format!(
+ "duplicate variant key {} with differing messages",
+ duplicate_key
+ ),
+ );
+ error.combine(syn::Error::new(
+ original_span,
+ "originally used here",
+ ));
+ self.extractor.add_error(&self.file, error);
+ }
+ for error in errors {
+ self.extractor.add_error(
+ &self.file,
+ syn::Error::new(
+ match message_literal {
+ EnumMessage::Absent { variant_span } => {
+ *variant_span
+ }
+ EnumMessage::Present {
+ message, ..
+ } => message.span(),
+ },
+ error,
+ ),
+ );
+ }
+ }
+ }
+ }
+ }
+}
+
+enum I18nEnumMacroInvocation {
+ Transparent,
+ Enum {
+ for_enum: Ident,
+ root_key: LitStr,
+ variants: Punctuated,
+ },
+}
+
+impl Parse for I18nEnumMacroInvocation {
+ fn parse(input: ParseStream) -> syn::Result {
+ if input.peek(kw::transparent) {
+ input.parse::()?;
+ input.parse::()?;
+ let field_content;
+ bracketed!(field_content in input);
+ field_content.parse::()?;
+ field_content.parse::()?;
+ field_content.parse::()?;
+ return Ok(I18nEnumMacroInvocation::Transparent);
+ }
+
+ let for_enum = input.parse()?;
+ input.parse::()?;
+
+ input.parse::()?;
+ input.parse::()?;
+ let root_key = input.parse()?;
+ input.parse::()?;
+
+ let variants =
+ input.parse_terminated(I18nEnumVariant::parse, Token![,])?;
+
+ Ok(I18nEnumMacroInvocation::Enum {
+ for_enum,
+ root_key,
+ variants,
+ })
+ }
+}
+
+struct I18nEnumVariant {
+ variant_name: Ident,
+ transparent: Option,
+ fields_type: FieldsType,
+ fields: Punctuated,
+ key: LitStr,
+}
+
+impl Parse for I18nEnumVariant {
+ fn parse(input: ParseStream) -> syn::Result {
+ let variant_name = input.parse()?;
+
+ let mut transparent = None;
+ let mut fields = Punctuated::new();
+ let fields_type;
+ if input.peek(Token![!]) || input.peek(Token![=>]) {
+ // Immediate => also flows into this case for a better error message
+ fields_type = FieldsType::Unit;
+ input.parse::()?;
+ } else {
+ let content;
+ if input.peek(token::Brace) {
+ fields_type = FieldsType::Named;
+ braced!(content in input);
+ } else {
+ fields_type = FieldsType::Tuple;
+ parenthesized!(content in input);
+ }
+ if content.peek(Token![..]) {
+ content.parse::()?;
+ } else if content.peek(kw::transparent) {
+ transparent = Some(content.parse()?);
+ content.parse::()?;
+ } else if content.peek(Ident) {
+ loop {
+ fields.push_value(content.parse()?);
+ if !content.peek(Token![,]) || !content.peek2(Ident) {
+ break;
+ }
+ fields.push_punct(content.parse()?);
+ }
+ }
+ if fields_type == FieldsType::Named && content.peek(Token![,]) {
+ content.parse::()?;
+ content.parse::()?;
+ }
+ };
+
+ input.parse::]>()?;
+
+ let key = input.parse()?;
+
+ Ok(Self {
+ variant_name,
+ transparent,
+ fields_type,
+ fields,
+ key,
+ })
+ }
+}
+
+impl I18nEnumVariant {
+ fn transform_format_string(
+ &self,
+ format_string: String,
+ display_attribute_type: DisplayAttributeType,
+ ) -> (String, Vec) {
+ let mut errors = vec![];
+ if !format_string.contains(['\'', '{', '}']) {
+ return (format_string, errors);
+ }
+
+ let known_names = if self.fields_type == FieldsType::Named {
+ self.fields.iter().map(Ident::to_string).collect()
+ } else {
+ HashSet::new()
+ };
+
+ let mut result = String::new();
+
+ let mut prev_push_index = 0;
+ let mut format_start = None;
+ let mut extra_format_layers = 0usize;
+ let mut iter = format_string.bytes().enumerate().peekable();
+ while let Some((index, char)) = iter.next() {
+ if char == b'\'' {
+ result.push_str(&format_string[prev_push_index..index + 1]);
+ result.push('\'');
+ prev_push_index = index + 1;
+ }
+ if char == b'{' {
+ if format_start.is_some() {
+ extra_format_layers += 1;
+ continue;
+ }
+ result.push_str(&format_string[prev_push_index..index]);
+ if matches!(iter.peek(), Some((_, b'{'))) {
+ iter.next();
+ result.push_str("'{'");
+ prev_push_index = index + 2;
+ } else {
+ result.push('{');
+ prev_push_index = index + 1;
+ format_start = Some(index + 1);
+ }
+ continue;
+ }
+ if char == b'}' {
+ if extra_format_layers > 0 {
+ extra_format_layers -= 1;
+ continue;
+ }
+ if let Some(prev_start) = format_start {
+ let format_variable = &format_string[prev_start..index];
+ let format_variable = match format_variable.split_once(':')
+ {
+ Some((real_variable, _)) => {
+ errors.push("format specifiers not allowed".into());
+ real_variable
+ }
+ None => format_variable,
+ };
+ let format_variable = match self.fields_type {
+ FieldsType::Unit => {
+ errors.push(
+ "formatting not supported for unit variants"
+ .into(),
+ );
+ format_variable
+ }
+ FieldsType::Tuple => {
+ let format_variable = match display_attribute_type {
+ DisplayAttributeType::ThiserrorError => {
+ format_variable
+ }
+ DisplayAttributeType::DeriveMoreDisplay => {
+ match format_variable.strip_prefix('_') {
+ Some(stripped) => stripped,
+ None => {
+ errors.push(format!("index format variable in #[display] must be prefixed with '_': {{_{format_variable}}}"));
+ format_variable
+ }
+ }
+ }
+ };
+ match format_variable
+ .parse()
+ .map(|x| self.fields.get(x))
+ {
+ Ok(Some(field)) => &field.to_string(),
+ Ok(None) => {
+ errors.push(format!("index format variable {{{format_variable}}} out of bounds"));
+ format_variable
+ }
+ Err(_) => {
+ errors.push(format!("invalid index format variable {{{format_variable}}} (must be usize)"));
+ format_variable
+ }
+ }
+ }
+ FieldsType::Named => {
+ if !known_names.contains(format_variable) {
+ errors.push(format!("unknown format variable {{{format_variable}}}"))
+ }
+ format_variable
+ }
+ };
+ result.push_str(format_variable);
+ format_start = None;
+ prev_push_index = index;
+ continue;
+ } else if matches!(iter.peek(), Some((_, b'}'))) {
+ iter.next();
+ result.push_str(&format_string[prev_push_index..index]);
+ result.push_str("'}'");
+ prev_push_index = index + 2;
+ continue;
+ } else {
+ errors.push("unmatched } in format string".into());
+ }
+ }
+ }
+ result.push_str(&format_string[prev_push_index..]);
+
+ (result, errors)
+ }
+}
+
+#[derive(Eq, PartialEq)]
+enum FieldsType {
+ Unit,
+ Tuple,
+ Named,
+}
+
+mod kw {
+ use syn::custom_keyword;
+
+ custom_keyword!(transparent);
+ custom_keyword!(root_key);
+}
diff --git a/apps/ariadne-extract/src/main.rs b/apps/ariadne-extract/src/main.rs
new file mode 100644
index 0000000000..7bb3da20e2
--- /dev/null
+++ b/apps/ariadne-extract/src/main.rs
@@ -0,0 +1,60 @@
+mod error;
+mod extractor;
+
+use crate::extractor::Extractor;
+use clap::Parser;
+use error::Result;
+use miette::GraphicalReportHandler;
+use std::fs;
+use std::fs::File;
+use std::path::PathBuf;
+use std::process::exit;
+
+#[derive(Debug, Clone, Parser)]
+struct Args {
+ /// The path to output the extracted translation data to
+ #[clap(short, long)]
+ out_file: PathBuf,
+
+ /// Include translations from tests. For simplicity, `cfg` above a module declarations are
+ /// skipped.
+ #[clap(short = 't', long)]
+ include_tests: bool,
+
+ /// The paths to the directories containing Cargo.toml that should be extracted to the specified file
+ packages: Vec,
+}
+
+fn main() {
+ match run(Args::parse()) {
+ Ok(exit_code) => exit(exit_code),
+ Err(err) => {
+ eprintln!("Failed to run extractor:\n{err}");
+ exit(1);
+ }
+ }
+}
+
+fn run(args: Args) -> Result {
+ let mut extractor = Extractor::new(args.include_tests);
+ for package in args.packages {
+ extractor.process_package(&package)?;
+ }
+ if !extractor.errors().is_empty() {
+ for error in extractor.errors() {
+ let mut error_message = String::new();
+ GraphicalReportHandler::new()
+ .render_report(&mut error_message, error)
+ .unwrap();
+ eprintln!("{}", error.render());
+ }
+ }
+
+ if let Some(parent) = args.out_file.parent() {
+ fs::create_dir_all(parent)?;
+ }
+ let writer = File::create(args.out_file)?;
+ serde_json::to_writer_pretty(writer, &extractor.output())?;
+
+ Ok(extractor.errors().len().try_into().unwrap_or(i32::MAX))
+}
diff --git a/apps/daedalus_client/Cargo.toml b/apps/daedalus_client/Cargo.toml
index feb1f52dc9..7d68846215 100644
--- a/apps/daedalus_client/Cargo.toml
+++ b/apps/daedalus_client/Cargo.toml
@@ -11,7 +11,7 @@ daedalus.workspace = true
tokio = { workspace = true, features = ["sync", "macros", "rt-multi-thread"] }
futures.workspace = true
dotenvy.workspace = true
-serde = { workspace = true, features = ["derive"] }
+serde.workspace = true
serde_json.workspace = true
serde-xml-rs.workspace = true
thiserror.workspace = true
diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json
new file mode 100644
index 0000000000..6b9d641623
--- /dev/null
+++ b/apps/frontend/src/locales/en-US/labrinth.json
@@ -0,0 +1,944 @@
+{
+ "labrinth.error.clickhouse_error": {
+ "message": "Clickhouse Error: {cause}"
+ },
+ "labrinth.error.conflict": {
+ "message": "Conflict: {cause}"
+ },
+ "labrinth.error.creation.database_error": {
+ "message": "Database Error: {cause}"
+ },
+ "labrinth.error.creation.database_error.unknown": {
+ "message": "An unknown database error occurred"
+ },
+ "labrinth.error.creation.environment_error": {
+ "message": "Environment Error"
+ },
+ "labrinth.error.creation.file_hosting_error": {
+ "message": "Error while uploading file: {cause}"
+ },
+ "labrinth.error.creation.indexing_error": {
+ "message": "Indexing Error: {cause}"
+ },
+ "labrinth.error.creation.invalid_image": {
+ "message": "Image Parsing Error: {cause}"
+ },
+ "labrinth.error.creation.invalid_input": {
+ "message": "Error with multipart data: {cause}"
+ },
+ "labrinth.error.creation.invalid_input.cannot_request_status": {
+ "message": "Status ''{status}'' cannot be requested"
+ },
+ "labrinth.error.creation.invalid_input.category": {
+ "message": "Invalid category: {category}"
+ },
+ "labrinth.error.creation.invalid_input.data_field_out_of_order": {
+ "message": "`data` field must come before file fields"
+ },
+ "labrinth.error.creation.invalid_input.duplicate_files": {
+ "message": "Duplicate files are not allowed to be uploaded to Modrinth!"
+ },
+ "labrinth.error.creation.invalid_input.duplicate_multipart_field": {
+ "message": "Duplicate multipart field name"
+ },
+ "labrinth.error.creation.invalid_input.failed_getting_new_team": {
+ "message": "Failed to get created team."
+ },
+ "labrinth.error.creation.invalid_input.file": {
+ "message": "Error while validating uploaded file: {cause}"
+ },
+ "labrinth.error.creation.invalid_input.file_name_has_slashes": {
+ "message": "File names must not contain slashes!"
+ },
+ "labrinth.error.creation.invalid_input.file_not_specified": {
+ "message": "File `{file_name}` (field {in_field}) isn''t specified in the versions data"
+ },
+ "labrinth.error.creation.invalid_input.file_type": {
+ "message": "Invalid file type for version file: {extension}"
+ },
+ "labrinth.error.creation.invalid_input.gallery_image_too_large": {
+ "message": "Gallery image exceeds the maximum of {limit}."
+ },
+ "labrinth.error.creation.invalid_input.icon": {
+ "message": "Invalid format for image: {cause}"
+ },
+ "labrinth.error.creation.invalid_input.icon_too_large": {
+ "message": "Icons must be smaller than {limit}"
+ },
+ "labrinth.error.creation.invalid_input.improper_context_image": {
+ "message": "Image {image_id} is not unused or in the ''{proper_context}'' context"
+ },
+ "labrinth.error.creation.invalid_input.initial_versions_files_missing": {
+ "message": "Some files were specified in initial_versions but not uploaded"
+ },
+ "labrinth.error.creation.invalid_input.invalid_license_id": {
+ "message": "Invalid SPDX license identifier: {cause}"
+ },
+ "labrinth.error.creation.invalid_input.invalid_organization_id": {
+ "message": "Invalid organization ID specified!"
+ },
+ "labrinth.error.creation.invalid_input.invalid_project_id": {
+ "message": "An invalid project id was supplied"
+ },
+ "labrinth.error.creation.invalid_input.invalid_version_id": {
+ "message": "An invalid version id was supplied"
+ },
+ "labrinth.error.creation.invalid_input.loader": {
+ "message": "Invalid loader: {loader}"
+ },
+ "labrinth.error.creation.invalid_input.missing_any_files": {
+ "message": "Versions must have at least one file uploaded to them"
+ },
+ "labrinth.error.creation.invalid_input.missing_data_field": {
+ "message": "`data` field is required"
+ },
+ "labrinth.error.creation.invalid_input.missing_loader_fields": {
+ "message": "Missing mandatory loader fields: {fields}"
+ },
+ "labrinth.error.creation.invalid_input.multipart": {
+ "message": "Error while parsing multipart payload: {cause}"
+ },
+ "labrinth.error.creation.invalid_input.multiple_featured_gallery": {
+ "message": "Only one gallery image can be featured."
+ },
+ "labrinth.error.creation.invalid_input.multiple_icons": {
+ "message": "Projects can only have one icon"
+ },
+ "labrinth.error.creation.invalid_input.no_files_specified": {
+ "message": "At least one file must be specified"
+ },
+ "labrinth.error.creation.invalid_input.no_initial_versions": {
+ "message": "Project submitted for review with no initial versions"
+ },
+ "labrinth.error.creation.invalid_input.no_json_in_multipart": {
+ "message": "No json segment found in multipart."
+ },
+ "labrinth.error.creation.invalid_input.nonexistent_image": {
+ "message": "Image {image_id} does not exist"
+ },
+ "labrinth.error.creation.invalid_input.nonexistent_link_platform": {
+ "message": "Link platform {platform} does not exist."
+ },
+ "labrinth.error.creation.invalid_input.nonexistent_loader_field": {
+ "message": "Loader field ''{field}'' does not exist for any loaders supplied"
+ },
+ "labrinth.error.creation.invalid_input.parsing": {
+ "message": "Error while parsing JSON: {cause}"
+ },
+ "labrinth.error.creation.invalid_input.project_file_too_large": {
+ "message": "Project file exceeds the maximum of {limit}. Contact a moderator or admin to request permission to upload larger files."
+ },
+ "labrinth.error.creation.invalid_input.project_id_in_initial_version": {
+ "message": "Found project id in initial version for new project"
+ },
+ "labrinth.error.creation.invalid_input.slug_collision": {
+ "message": "Slug is already taken!"
+ },
+ "labrinth.error.creation.invalid_input.validation": {
+ "message": "Error while validating input: {cause}"
+ },
+ "labrinth.error.creation.missing_value.content_file_extension": {
+ "message": "Missing content file extension"
+ },
+ "labrinth.error.creation.missing_value.content_file_name": {
+ "message": "Missing content file name"
+ },
+ "labrinth.error.creation.missing_value.content_name": {
+ "message": "Missing content name"
+ },
+ "labrinth.error.creation.missing_value.data_field": {
+ "message": "No `data` field in multipart upload"
+ },
+ "labrinth.error.creation.missing_value.project_id": {
+ "message": "Missing project id"
+ },
+ "labrinth.error.creation.unauthorized": {
+ "message": "Authentication Error: {cause_or_reason}"
+ },
+ "labrinth.error.creation.unauthorized.create_projects_in_organization": {
+ "message": "You do not have the permissions to create projects in this organization!"
+ },
+ "labrinth.error.creation.unauthorized.upload_files_to_version": {
+ "message": "You don''t have permission to upload files to this version!"
+ },
+ "labrinth.error.creation.unauthorized.upload_version": {
+ "message": "You don''t have permission to upload this version!"
+ },
+ "labrinth.error.database.cache": {
+ "message": "Error while interacting with the cache: {cause}"
+ },
+ "labrinth.error.database.cache_serialization": {
+ "message": "Error while serializing with the cache: {cause}"
+ },
+ "labrinth.error.database.cache_timeout": {
+ "message": "Timeout when waiting for cache subscriber"
+ },
+ "labrinth.error.database.random_id": {
+ "message": "Error while trying to generate random ID"
+ },
+ "labrinth.error.database.redis_pool": {
+ "message": "Redis Pool Error: {cause}"
+ },
+ "labrinth.error.database.schema": {
+ "message": "Schema error: {cause}"
+ },
+ "labrinth.error.database.schema.field_name_does_not_exist": {
+ "message": "Field name {field_name} for field {field} in does not exist"
+ },
+ "labrinth.error.database.schema.field_name_mismatch": {
+ "message": "Field name {expected} is not {actual}"
+ },
+ "labrinth.error.database.schema.game_version_field_not_enum": {
+ "message": "Game version requires field value to be an enum: {field}"
+ },
+ "labrinth.error.database.schema.missing_game_version_enum": {
+ "message": "Could not find game version enum."
+ },
+ "labrinth.error.database.schema.multiple_fields": {
+ "message": "Multiple fields for field {field}"
+ },
+ "labrinth.error.database.schema.multiple_ids_for_field": {
+ "message": "Multiple field ids for field {field}"
+ },
+ "labrinth.error.database.schema.no_version_fields": {
+ "message": "No version fields for field {field}"
+ },
+ "labrinth.error.database.sqlx": {
+ "message": "Error while interacting with the database: {cause}"
+ },
+ "labrinth.error.database_error": {
+ "message": "Database Error: {cause}"
+ },
+ "labrinth.error.decoding_error": {
+ "message": "Error while decoding Base62: {cause}"
+ },
+ "labrinth.error.discord_error": {
+ "message": "Discord Error: {cause}"
+ },
+ "labrinth.error.environment_error": {
+ "message": "Environment Error"
+ },
+ "labrinth.error.file_hosting_error": {
+ "message": "Error while uploading file: {cause}"
+ },
+ "labrinth.error.file_hosting_error.file_system": {
+ "message": "File system error in file hosting: {cause}"
+ },
+ "labrinth.error.file_hosting_error.invalid_filename": {
+ "message": "Invalid Filename"
+ },
+ "labrinth.error.file_hosting_error.s3": {
+ "message": "{action}: {cause}"
+ },
+ "labrinth.error.file_hosting_error.s3.creating_bucket_instance": {
+ "message": "S3 error when creating bucket instance"
+ },
+ "labrinth.error.file_hosting_error.s3.deleting_file": {
+ "message": "S3 error when deleting file"
+ },
+ "labrinth.error.file_hosting_error.s3.generating_presigned_url": {
+ "message": "S3 error when generating presigned URL"
+ },
+ "labrinth.error.file_hosting_error.s3.uploading_file": {
+ "message": "S3 error when uploading file"
+ },
+ "labrinth.error.file_validation.blocking": {
+ "message": "Error while managing threads"
+ },
+ "labrinth.error.file_validation.database": {
+ "message": "Error while querying database"
+ },
+ "labrinth.error.file_validation.invalid_input": {
+ "message": "Invalid Input: {cause}"
+ },
+ "labrinth.error.file_validation.io": {
+ "message": "IO Error: {cause}"
+ },
+ "labrinth.error.file_validation.serialization": {
+ "message": "Error while validating JSON for uploaded file: {cause}"
+ },
+ "labrinth.error.file_validation.zip": {
+ "message": "Unable to read Zip Archive: {cause}"
+ },
+ "labrinth.error.indexing.database": {
+ "message": "Database Error: {cause}"
+ },
+ "labrinth.error.indexing.environment": {
+ "message": "Environment Error"
+ },
+ "labrinth.error.indexing.meilisearch": {
+ "message": "Error while connecting to the MeiliSearch database"
+ },
+ "labrinth.error.indexing.serialization": {
+ "message": "Error while serializing or deserializing JSON: {cause}"
+ },
+ "labrinth.error.indexing.sqlx": {
+ "message": "Database Error: {cause}"
+ },
+ "labrinth.error.indexing.task": {
+ "message": "Error while awaiting index creation task"
+ },
+ "labrinth.error.indexing_error": {
+ "message": "Indexing Error: {cause}"
+ },
+ "labrinth.error.invalid_image": {
+ "message": "Image Parsing Error: {cause}"
+ },
+ "labrinth.error.invalid_input": {
+ "message": "Invalid Input: {cause}"
+ },
+ "labrinth.error.invalid_input.validation": {
+ "message": "Error while validating input: {cause}"
+ },
+ "labrinth.error.io_error": {
+ "message": "IO Error: {cause}"
+ },
+ "labrinth.error.json_error": {
+ "message": "Deserialization error: {cause}"
+ },
+ "labrinth.error.mail.address": {
+ "message": "Address Parse Error: {cause}"
+ },
+ "labrinth.error.mail.email": {
+ "message": "Mail Error: {cause}"
+ },
+ "labrinth.error.mail.environment": {
+ "message": "Environment Error"
+ },
+ "labrinth.error.mail.http_template": {
+ "message": "HTTP error fetching template: {cause}"
+ },
+ "labrinth.error.mail.smtp": {
+ "message": "SMTP Error: {cause}"
+ },
+ "labrinth.error.mail.uninitialized": {
+ "message": "Couldn''t initialize SMTP transport"
+ },
+ "labrinth.error.not_found": {
+ "message": "Resource not found"
+ },
+ "labrinth.error.not_found.route": {
+ "message": "The requested route does not exist"
+ },
+ "labrinth.error.oauth.access_denied": {
+ "message": "The resource owner denied the request"
+ },
+ "labrinth.error.oauth.invalid_client.auth_failed": {
+ "message": "Failed to authenticate client"
+ },
+ "labrinth.error.oauth.invalid_client.invalid_id": {
+ "message": "The provided client id was invalid"
+ },
+ "labrinth.error.oauth.invalid_grant.invalid_auth_code": {
+ "message": "The provided authorization grant code was invalid"
+ },
+ "labrinth.error.oauth.invalid_grant.invalid_grant_type": {
+ "message": "The provided grant type ({grant_type}) must be \"authorization_code\""
+ },
+ "labrinth.error.oauth.invalid_request.malformed_id": {
+ "message": "The provided ID could not be decoded: {cause}"
+ },
+ "labrinth.error.oauth.invalid_request.redirect_uri_mismatch": {
+ "message": "The provided redirect URI did not exactly match the uri originally provided when this flow began"
+ },
+ "labrinth.error.oauth.invalid_scope.parse": {
+ "message": "The provided scope was malformed or did not correspond to known scopes ({cause})"
+ },
+ "labrinth.error.oauth.invalid_scope.too_broad": {
+ "message": "The provided scope requested scopes broader than the developer app is configured with"
+ },
+ "labrinth.error.oauth.invalid_uri.none_configured": {
+ "message": "The provided redirect URI did not match any configured in the client"
+ },
+ "labrinth.error.oauth.invalid_uri.none_specified": {
+ "message": "Client {client_id} has no redirect URIs specified"
+ },
+ "labrinth.error.oauth.server_error.invalid_flow_id": {
+ "message": "The provided flow id was invalid"
+ },
+ "labrinth.error.oauth.unauthorized_client": {
+ "message": "The provided client id did not match the id this authorization code was granted to"
+ },
+ "labrinth.error.password_hashing_error": {
+ "message": "Password Hashing Error: {cause}"
+ },
+ "labrinth.error.payments_error": {
+ "message": "Payments Error: {cause}"
+ },
+ "labrinth.error.ratelimit_error": {
+ "message": "You are being rate-limited. Please wait {wait_ms} milliseconds. 0/{total_allowed_requests} remaining."
+ },
+ "labrinth.error.reroute_error": {
+ "message": "Error while rerouting request: {cause}"
+ },
+ "labrinth.error.search.environment_error": {
+ "message": "Environment Error"
+ },
+ "labrinth.error.search.invalid_input.formatting": {
+ "message": "Error while formatting strings: {cause}"
+ },
+ "labrinth.error.search.invalid_input.index": {
+ "message": "Invalid index to sort by: {index}"
+ },
+ "labrinth.error.search.invalid_input.int_parsing": {
+ "message": "Error while parsing an integer: {cause}"
+ },
+ "labrinth.error.search.invalid_input.serialization": {
+ "message": "Error while serializing or deserializing JSON: {cause}"
+ },
+ "labrinth.error.search.meilisearch_error": {
+ "message": "MeiliSearch Error: {cause}"
+ },
+ "labrinth.error.search_error": {
+ "message": "Search Error: {cause}"
+ },
+ "labrinth.error.slack_error": {
+ "message": "Slack Webhook Error: Error while sending projects webhook"
+ },
+ "labrinth.error.stripe_error": {
+ "message": "Error while interacting with payment processor: {cause}"
+ },
+ "labrinth.error.tax_compliance_api_error": {
+ "message": "External tax compliance API Error"
+ },
+ "labrinth.error.turnstile_error": {
+ "message": "Captcha Error. Try resubmitting the form."
+ },
+ "labrinth.error.unauthorized": {
+ "message": "Authentication Error: {cause}"
+ },
+ "labrinth.error.unauthorized.database_error": {
+ "message": "Database Error: {cause}"
+ },
+ "labrinth.error.unauthorized.database_error.unknown": {
+ "message": "An unknown database error occurred: {cause}"
+ },
+ "labrinth.error.unauthorized.decoding_error": {
+ "message": "Error while decoding PAT: {cause}"
+ },
+ "labrinth.error.unauthorized.duplicate_user": {
+ "message": "User email is already registered on Modrinth. Try ''Forgot password'' to access your account."
+ },
+ "labrinth.error.unauthorized.environment_error": {
+ "message": "Environment Error"
+ },
+ "labrinth.error.unauthorized.file_hosting": {
+ "message": "Error uploading user profile picture"
+ },
+ "labrinth.error.unauthorized.invalid_auth_method": {
+ "message": "Authentication method was not valid"
+ },
+ "labrinth.error.unauthorized.invalid_client_id": {
+ "message": "GitHub Token from incorrect Client ID"
+ },
+ "labrinth.error.unauthorized.invalid_credentials": {
+ "message": "Invalid Authentication Credentials"
+ },
+ "labrinth.error.unauthorized.invalid_input": {
+ "message": "Error while parsing JSON: {cause}"
+ },
+ "labrinth.error.unauthorized.network_error": {
+ "message": "Error while communicating to external provider"
+ },
+ "labrinth.error.unauthorized.socket": {
+ "message": "Invalid state sent, you probably need to get a new websocket"
+ },
+ "labrinth.error.unauthorized.specific.add_to_org": {
+ "message": "You do not have permission to add projects to this organization!"
+ },
+ "labrinth.error.unauthorized.specific.bulk_edit_project": {
+ "message": "You do not have the permissions to bulk edit project {project_name}!"
+ },
+ "labrinth.error.unauthorized.specific.cancel_org_invite": {
+ "message": "You do not have permission to cancel an organization invite"
+ },
+ "labrinth.error.unauthorized.specific.cancel_team_invite": {
+ "message": "You do not have permission to cancel a team invite"
+ },
+ "labrinth.error.unauthorized.specific.create_affiliate_code": {
+ "message": "You do not have permission to create an affiliate code!"
+ },
+ "labrinth.error.unauthorized.specific.delete_affiliate_code": {
+ "message": "You do not have permission to delete an affiliate code!"
+ },
+ "labrinth.error.unauthorized.specific.delete_file": {
+ "message": "You don''t have permission to delete this file!"
+ },
+ "labrinth.error.unauthorized.specific.delete_message": {
+ "message": "You cannot delete this message!"
+ },
+ "labrinth.error.unauthorized.specific.delete_org": {
+ "message": "You don''t have permission to delete this organization!"
+ },
+ "labrinth.error.unauthorized.specific.delete_project": {
+ "message": "You don''t have permission to delete this project!"
+ },
+ "labrinth.error.unauthorized.specific.delete_shared_instance": {
+ "message": "You do not have permission to delete this shared instance."
+ },
+ "labrinth.error.unauthorized.specific.delete_shared_instance_version": {
+ "message": "You do not have permission to delete this shared instance version."
+ },
+ "labrinth.error.unauthorized.specific.delete_user": {
+ "message": "You do not have permission to delete this user!"
+ },
+ "labrinth.error.unauthorized.specific.delete_versions_in_team": {
+ "message": "You do not have permission to delete versions in this team"
+ },
+ "labrinth.error.unauthorized.specific.edit_org": {
+ "message": "You do not have permission to edit this organization!"
+ },
+ "labrinth.error.unauthorized.specific.edit_org_description": {
+ "message": "You do not have the permissions to edit the description of this organization!"
+ },
+ "labrinth.error.unauthorized.specific.edit_org_icon": {
+ "message": "You don''t have permission to edit this organization''s icon."
+ },
+ "labrinth.error.unauthorized.specific.edit_org_name": {
+ "message": "You do not have the permissions to edit the name of this organization!"
+ },
+ "labrinth.error.unauthorized.specific.edit_org_slug": {
+ "message": "You do not have the permissions to edit the slug of this organization!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project": {
+ "message": "You do not have permission to edit this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_additional_categories": {
+ "message": "You do not have the permissions to edit the additional categories of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_categories": {
+ "message": "You do not have the permissions to edit the categories of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_description": {
+ "message": "You do not have the permissions to edit the description (body) of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_gallery": {
+ "message": "You don''t have permission to edit this project''s gallery."
+ },
+ "labrinth.error.unauthorized.specific.edit_project_icon": {
+ "message": "You don''t have permission to edit this project''s icon."
+ },
+ "labrinth.error.unauthorized.specific.edit_project_license": {
+ "message": "You do not have the permissions to edit the license of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_license_url": {
+ "message": "You do not have the permissions to edit the license URL of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_links": {
+ "message": "You do not have the permissions to edit the links of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_moderation_message": {
+ "message": "You do not have the permissions to edit the moderation message of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_moderation_message_body": {
+ "message": "You do not have the permissions to edit the moderation message body of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_monetization_status": {
+ "message": "You do not have the permissions to edit the monetization status of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_name": {
+ "message": "You do not have the permissions to edit the name of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_requested_status": {
+ "message": "You do not have the permissions to edit the requested status of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_side_types_migration_review_status": {
+ "message": "You do not have the permissions to edit the side types migration review status of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_slug": {
+ "message": "You do not have the permissions to edit the slug of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_status": {
+ "message": "You do not have the permissions to edit the status of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_project_summary": {
+ "message": "You do not have the permissions to edit the summary of this project!"
+ },
+ "labrinth.error.unauthorized.specific.edit_shared_instance": {
+ "message": "You do not have permission to edit this shared instance."
+ },
+ "labrinth.error.unauthorized.specific.edit_team_members": {
+ "message": "You don''t have permission to edit members of this team"
+ },
+ "labrinth.error.unauthorized.specific.edit_team_ownership": {
+ "message": "You don''t have permission to edit the ownership of this team"
+ },
+ "labrinth.error.unauthorized.specific.edit_user": {
+ "message": "You do not have permission to edit this user!"
+ },
+ "labrinth.error.unauthorized.specific.edit_user_badges": {
+ "message": "You do not have the permissions to edit the badges of this user!"
+ },
+ "labrinth.error.unauthorized.specific.edit_user_icon": {
+ "message": "You don''t have permission to edit this user''s icon."
+ },
+ "labrinth.error.unauthorized.specific.edit_user_role": {
+ "message": "You do not have the permissions to edit the role of this user!"
+ },
+ "labrinth.error.unauthorized.specific.edit_user_venmo_handle": {
+ "message": "You do not have the permissions to edit the venmo handle of this user!"
+ },
+ "labrinth.error.unauthorized.specific.edit_version": {
+ "message": "You do not have the permissions to edit this version!"
+ },
+ "labrinth.error.unauthorized.specific.get_user_from_email": {
+ "message": "You do not have permission to get a user from their email!"
+ },
+ "labrinth.error.unauthorized.specific.give_user_default_project_permissions": {
+ "message": "You do not have permission to give this user default project permissions."
+ },
+ "labrinth.error.unauthorized.specific.insufficient_oauth_permissions": {
+ "message": "You don''t have sufficient permissions to interact with this OAuth application"
+ },
+ "labrinth.error.unauthorized.specific.invalid_flow_code": {
+ "message": "The password change flow code is invalid or has expired. Did you copy it promptly and correctly?"
+ },
+ "labrinth.error.unauthorized.specific.invalid_master_key": {
+ "message": "Invalid master key"
+ },
+ "labrinth.error.unauthorized.specific.invite_users_to_org": {
+ "message": "You don''t have permission to invite users to this organization"
+ },
+ "labrinth.error.unauthorized.specific.invite_users_to_team": {
+ "message": "You don''t have permission to invite users to this team"
+ },
+ "labrinth.error.unauthorized.specific.maximum_gallery_images": {
+ "message": "You have reached the maximum of gallery images to upload."
+ },
+ "labrinth.error.unauthorized.specific.not_member_of_project": {
+ "message": "You are not a member of project {project_name}!"
+ },
+ "labrinth.error.unauthorized.specific.not_project_owner_for_add_to_org": {
+ "message": "You need to be an owner of a project to add it to an organization!"
+ },
+ "labrinth.error.unauthorized.specific.notification_delete": {
+ "message": "You are not authorized to delete this notification!"
+ },
+ "labrinth.error.unauthorized.specific.notification_read": {
+ "message": "You are not authorized to read this notification!"
+ },
+ "labrinth.error.unauthorized.specific.old_password_not_specified": {
+ "message": "You must specify the old password to change your password!"
+ },
+ "labrinth.error.unauthorized.specific.override_org_owner_default_project_permissions": {
+ "message": "You cannot override the project permissions of the organization owner!"
+ },
+ "labrinth.error.unauthorized.specific.read_affiliate_code": {
+ "message": "You do not have permission to read an affiliate code!"
+ },
+ "labrinth.error.unauthorized.specific.read_all_affiliate_codes": {
+ "message": "You do not have permission to read all affiliate codes!"
+ },
+ "labrinth.error.unauthorized.specific.refund": {
+ "message": "You do not have permission to refund a subscription!"
+ },
+ "labrinth.error.unauthorized.specific.remove_org_member": {
+ "message": "You do not have permission to remove a member from this organization"
+ },
+ "labrinth.error.unauthorized.specific.remove_owner_from_team": {
+ "message": "The owner can''t be removed from a team"
+ },
+ "labrinth.error.unauthorized.specific.remove_team_member": {
+ "message": "You do not have permission to remove a member from this team"
+ },
+ "labrinth.error.unauthorized.specific.restricted_project_status": {
+ "message": "You don''t have permission to set this status!"
+ },
+ "labrinth.error.unauthorized.specific.see_oauth_clients": {
+ "message": "You do not have permission to see the OAuth clients of this user!"
+ },
+ "labrinth.error.unauthorized.specific.see_user_follows": {
+ "message": "You do not have permission to see the projects this user follows!"
+ },
+ "labrinth.error.unauthorized.specific.see_user_notifications": {
+ "message": "You do not have permission to see the notifications of this user!"
+ },
+ "labrinth.error.unauthorized.specific.set_mod_downloads": {
+ "message": "You don''t have permission to set the downloads of this mod"
+ },
+ "labrinth.error.unauthorized.specific.set_status": {
+ "message": "You don''t have permission to set this status!"
+ },
+ "labrinth.error.unauthorized.specific.shared_instance_upload_version": {
+ "message": "You do not have permission to upload a version for this shared instance."
+ },
+ "labrinth.error.unauthorized.specific.unknown_affiliate_user": {
+ "message": "Affiliate user not found!"
+ },
+ "labrinth.error.unauthorized.specific.unknown_user_ip": {
+ "message": "Unable to obtain user IP address!"
+ },
+ "labrinth.error.unauthorized.specific.upload_project_images": {
+ "message": "You are not authorized to upload images for this project"
+ },
+ "labrinth.error.unauthorized.specific.upload_report_images": {
+ "message": "You are not authorized to upload images for this report"
+ },
+ "labrinth.error.unauthorized.specific.upload_thread_message_images": {
+ "message": "You are not authorized to upload images for this thread message"
+ },
+ "labrinth.error.unauthorized.specific.upload_version_images": {
+ "message": "You are not authorized to upload images for this version"
+ },
+ "labrinth.error.unauthorized.url_error": {
+ "message": "Invalid callback URL specified"
+ },
+ "labrinth.error.version_field_parse.invalid_enum_variant": {
+ "message": "Provided value {value} is not a valid variant for {field_name}"
+ },
+ "labrinth.error.version_field_parse.parse_failure": {
+ "message": "Provided value {value} for {field_name} could not be parsed to {field_type}"
+ },
+ "labrinth.error.version_field_parse.value_greater_than_maximum": {
+ "message": "Provided value {value} for {field_name} is greater than the maximum of {maximum}"
+ },
+ "labrinth.error.version_field_parse.value_less_than_minimum": {
+ "message": "Provided value {value} for {field_name} is less than the minimum of {minimum}"
+ },
+ "labrinth.error.xml_error": {
+ "message": "Internal server error: {cause}"
+ },
+ "labrinth.error.zip_error": {
+ "message": "Unable to read Zip Archive: {cause}"
+ },
+ "labrinth.notification.body.auth_provider_added": {
+ "message": "You''ve added a new authentication provider to your account."
+ },
+ "labrinth.notification.body.auth_provider_removed": {
+ "message": "You''ve removed a authentication provider from your account."
+ },
+ "labrinth.notification.body.email_changed": {
+ "message": "Your account email was changed."
+ },
+ "labrinth.notification.body.moderation_message_received": {
+ "message": "You have a new message in a moderation thread."
+ },
+ "labrinth.notification.body.moderator_message": {
+ "message": "Click on the link to read more."
+ },
+ "labrinth.notification.body.organization_invite": {
+ "message": "An invite has been sent for you to be {role} of an organization"
+ },
+ "labrinth.notification.body.password_changed": {
+ "message": "You''ve changed your account password."
+ },
+ "labrinth.notification.body.password_removed": {
+ "message": "You''ve removed your account password."
+ },
+ "labrinth.notification.body.pat_created": {
+ "message": "Your personal access token ''{token_name}'' was created."
+ },
+ "labrinth.notification.body.payment_failed": {
+ "message": "A payment on your account failed. Please update your billing information."
+ },
+ "labrinth.notification.body.payout_available": {
+ "message": "A payout is available!"
+ },
+ "labrinth.notification.body.project_status_approved": {
+ "message": "Your project has been approved."
+ },
+ "labrinth.notification.body.project_status_neutral": {
+ "message": "Your project status has been updated."
+ },
+ "labrinth.notification.body.project_transferred": {
+ "message": "A project''s ownership has been transferred."
+ },
+ "labrinth.notification.body.project_update": {
+ "message": "The project {project_id} has released a new version: {version_id}"
+ },
+ "labrinth.notification.body.report_status_updated": {
+ "message": "A report you are involved in has been updated."
+ },
+ "labrinth.notification.body.report_submitted": {
+ "message": "Your report was submitted successfully."
+ },
+ "labrinth.notification.body.reset_password": {
+ "message": "You''ve requested to reset your password. Please check your email for a reset link."
+ },
+ "labrinth.notification.body.status_change": {
+ "message": "Status has changed from {old_status} to {new_status}"
+ },
+ "labrinth.notification.body.team_invite": {
+ "message": "An invite has been sent for you to be {role} of a team"
+ },
+ "labrinth.notification.body.two_factor_enabled": {
+ "message": "You''ve enabled two-factor authentication on your account."
+ },
+ "labrinth.notification.body.two_factor_removed": {
+ "message": "You''ve removed two-factor authentication from your account."
+ },
+ "labrinth.notification.body.unknown": {
+ "message": ""
+ },
+ "labrinth.notification.body.verify_email": {
+ "message": "You''ve requested to verify your email. Please check your email for a verification link."
+ },
+ "labrinth.notification.type.auth_provider_added": {
+ "message": "Auth provider added"
+ },
+ "labrinth.notification.type.auth_provider_removed": {
+ "message": "Auth provider removed"
+ },
+ "labrinth.notification.type.email_changed": {
+ "message": "Email changed"
+ },
+ "labrinth.notification.type.legacy_markdown": {
+ "message": "LEGACY MARKDOWN NOTIFICATION"
+ },
+ "labrinth.notification.type.moderation_message_received": {
+ "message": "New message in moderation thread"
+ },
+ "labrinth.notification.type.moderator_message": {
+ "message": "A moderator has sent you a message!"
+ },
+ "labrinth.notification.type.organization_invite": {
+ "message": "You have been invited to join an organization!"
+ },
+ "labrinth.notification.type.password_changed": {
+ "message": "Password changed"
+ },
+ "labrinth.notification.type.password_removed": {
+ "message": "Password removed"
+ },
+ "labrinth.notification.type.pat_created": {
+ "message": "New personal access token created"
+ },
+ "labrinth.notification.type.payment_failed": {
+ "message": "Payment failed"
+ },
+ "labrinth.notification.type.payout_available": {
+ "message": "Payout available"
+ },
+ "labrinth.notification.type.project_status_approved": {
+ "message": "Project approved"
+ },
+ "labrinth.notification.type.project_status_neutral": {
+ "message": "Project status updated"
+ },
+ "labrinth.notification.type.project_transferred": {
+ "message": "Project ownership transferred"
+ },
+ "labrinth.notification.type.project_update": {
+ "message": "A project you follow has been updated!"
+ },
+ "labrinth.notification.type.report_status_updated": {
+ "message": "Report status updated"
+ },
+ "labrinth.notification.type.report_submitted": {
+ "message": "Report submitted"
+ },
+ "labrinth.notification.type.reset_password": {
+ "message": "Password reset requested"
+ },
+ "labrinth.notification.type.status_change": {
+ "message": "Project status has changed"
+ },
+ "labrinth.notification.type.team_invite": {
+ "message": "You have been invited to join a team!"
+ },
+ "labrinth.notification.type.two_factor_enabled": {
+ "message": "Two-factor authentication enabled"
+ },
+ "labrinth.notification.type.two_factor_removed": {
+ "message": "Two-factor authentication removed"
+ },
+ "labrinth.notification.type.unknown": {
+ "message": ""
+ },
+ "labrinth.notification.type.verify_email": {
+ "message": "Verify your email"
+ },
+ "labrinth.project_status.approved": {
+ "message": "Listed"
+ },
+ "labrinth.project_status.archived": {
+ "message": "Archived"
+ },
+ "labrinth.project_status.draft": {
+ "message": "Draft"
+ },
+ "labrinth.project_status.private": {
+ "message": "Private"
+ },
+ "labrinth.project_status.processing": {
+ "message": "Under review"
+ },
+ "labrinth.project_status.rejected": {
+ "message": "Rejected"
+ },
+ "labrinth.project_status.scheduled": {
+ "message": "Scheduled"
+ },
+ "labrinth.project_status.unknown": {
+ "message": "Unknown"
+ },
+ "labrinth.project_status.unlisted": {
+ "message": "Unlisted"
+ },
+ "labrinth.project_status.withheld": {
+ "message": "Withheld"
+ },
+ "labrinth.warning.file_validation.invalid_mrpack": {
+ "message": "Invalid modpack file. You must upload a valid .mrpack file."
+ },
+ "labrinth.warning.file_validation.missing_bukkit_plugin_yml": {
+ "message": "No plugin.yml or paper-plugin.yml present for plugin file."
+ },
+ "labrinth.warning.file_validation.missing_bungeecord_plugin_yml": {
+ "message": "No plugin.yml or bungee.yml present for plugin file."
+ },
+ "labrinth.warning.file_validation.missing_canvas_pack_mcmeta": {
+ "message": "No pack.mcmeta present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!"
+ },
+ "labrinth.warning.file_validation.missing_datapack_pack_mcmeta": {
+ "message": "No pack.mcmeta present for datapack file. Tip: Make sure pack.mcmeta is in the root directory of your datapack!"
+ },
+ "labrinth.warning.file_validation.missing_fabric_mod_json": {
+ "message": "No fabric.mod.json present for Fabric file."
+ },
+ "labrinth.warning.file_validation.missing_litemod_json": {
+ "message": "No litemod.json present for LiteLoader file."
+ },
+ "labrinth.warning.file_validation.missing_mcmod_info": {
+ "message": "Forge mod file does not contain mcmod.info or valid class files!"
+ },
+ "labrinth.warning.file_validation.missing_mods_toml": {
+ "message": "No mods.toml or valid class files present for Forge file."
+ },
+ "labrinth.warning.file_validation.missing_neoforge_mods_toml": {
+ "message": "No neoforge.mods.toml, mods.toml, or valid class files present for NeoForge file."
+ },
+ "labrinth.warning.file_validation.missing_pack_manifest": {
+ "message": "Pack manifest is missing."
+ },
+ "labrinth.warning.file_validation.missing_pack_txt": {
+ "message": "No pack.txt present for pack file."
+ },
+ "labrinth.warning.file_validation.missing_pipelines_folder": {
+ "message": "No pipeline shaders folder present for canvas shaders."
+ },
+ "labrinth.warning.file_validation.missing_quilt_mod_json": {
+ "message": "No quilt.mod.json present for Quilt file."
+ },
+ "labrinth.warning.file_validation.missing_resource_pack_pack_mcmeta": {
+ "message": "No pack.mcmeta present for resource pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!"
+ },
+ "labrinth.warning.file_validation.missing_riftmod_json": {
+ "message": "No riftmod.json present for Rift file."
+ },
+ "labrinth.warning.file_validation.missing_shaders_folder": {
+ "message": "No shaders folder present for OptiFine/Iris shader."
+ },
+ "labrinth.warning.file_validation.missing_sponge_plugins_json": {
+ "message": "No sponge_plugins.json or mcmod.info present for Sponge plugin."
+ },
+ "labrinth.warning.file_validation.missing_vanilla_shaders_folder": {
+ "message": "No pack.mcmeta or vanilla shaders folder present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!"
+ },
+ "labrinth.warning.file_validation.missing_velocity_plugin_json": {
+ "message": "No velocity-plugin.json present for plugin file."
+ },
+ "labrinth.warning.file_validation.wrong_file_extension": {
+ "message": "File extension is invalid for input file"
+ }
+}
\ No newline at end of file
diff --git a/apps/labrinth/.sqlx/query-79c73369365ed7a09f4f48a87605d22db4a49ab5fd9943b54865448d0e9a8d67.json b/apps/labrinth/.sqlx/query-ad48051c9ac8f233ebfeeebec834ff96df8525a6ed872be7a64b8f349b36b276.json
similarity index 68%
rename from apps/labrinth/.sqlx/query-79c73369365ed7a09f4f48a87605d22db4a49ab5fd9943b54865448d0e9a8d67.json
rename to apps/labrinth/.sqlx/query-ad48051c9ac8f233ebfeeebec834ff96df8525a6ed872be7a64b8f349b36b276.json
index dd98633e57..99373fb6ad 100644
--- a/apps/labrinth/.sqlx/query-79c73369365ed7a09f4f48a87605d22db4a49ab5fd9943b54865448d0e9a8d67.json
+++ b/apps/labrinth/.sqlx/query-ad48051c9ac8f233ebfeeebec834ff96df8525a6ed872be7a64b8f349b36b276.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n INSERT INTO loader_field_enum_values (enum_id, value, created, metadata)\n VALUES ($1, $2, COALESCE($3, timezone('utc', now())), $4)\n ON CONFLICT (enum_id, value) DO UPDATE\n SET metadata = jsonb_set(\n COALESCE(loader_field_enum_values.metadata, $4),\n '{type}', \n COALESCE($4->'type', loader_field_enum_values.metadata->'type')\n ),\n created = COALESCE($3, loader_field_enum_values.created)\n RETURNING id\n ",
+ "query": "\n INSERT INTO loader_field_enum_values (enum_id, value, created, metadata)\n VALUES ($1, $2, COALESCE($3, timezone('utc', now())), $4)\n ON CONFLICT (enum_id, value) DO UPDATE\n SET metadata = jsonb_set(\n COALESCE(loader_field_enum_values.metadata, $4),\n '{type}',\n COALESCE($4->'type', loader_field_enum_values.metadata->'type')\n ),\n created = COALESCE($3, loader_field_enum_values.created)\n RETURNING id\n ",
"describe": {
"columns": [
{
@@ -21,5 +21,5 @@
false
]
},
- "hash": "79c73369365ed7a09f4f48a87605d22db4a49ab5fd9943b54865448d0e9a8d67"
+ "hash": "ad48051c9ac8f233ebfeeebec834ff96df8525a6ed872be7a64b8f349b36b276"
}
diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml
index fbc7c72cb3..f145566049 100644
--- a/apps/labrinth/Cargo.toml
+++ b/apps/labrinth/Cargo.toml
@@ -44,11 +44,11 @@ reqwest = { workspace = true, features = [
hyper-rustls.workspace = true
hyper-util.workspace = true
-serde = { workspace = true, features = ["derive"] }
+serde.workspace = true
serde_json.workspace = true
serde_with.workspace = true
chrono = { workspace = true, features = ["serde"] }
-yaserde = { workspace = true, features = ["derive"] }
+serde-xml-rs.workspace = true
rand.workspace = true
rand_chacha.workspace = true
@@ -79,6 +79,7 @@ spdx = { workspace = true, features = ["text"] }
dotenvy.workspace = true
thiserror.workspace = true
either.workspace = true
+derive_more.workspace = true
sqlx = { workspace = true, features = [
"runtime-tokio",
@@ -143,7 +144,7 @@ json-patch.workspace = true
ariadne.workspace = true
-clap = { workspace = true, features = ["derive"] }
+clap.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
tikv-jemallocator = { workspace = true, features = [
diff --git a/apps/labrinth/package.json b/apps/labrinth/package.json
index 40873a2bc1..dff74cc5a1 100644
--- a/apps/labrinth/package.json
+++ b/apps/labrinth/package.json
@@ -11,6 +11,7 @@
"//": "runners we must remove useless development tools from the base image, which frees up ~20 GiB.",
"//": "The command commented out below can be used in CI to debug what is taking up space:",
"//": "sudo du -xh --max-depth=4 / | sort -rh | curl -X POST --data-urlencode content@/dev/fd/0 https://api.mclo.gs/1/log",
+ "intl:extract": "cargo run --package ariadne-extract -- --out-file ../frontend/src/locales/en-US/labrinth.json .",
"test": "if-ci sudo rm -rf /usr/local/lib/android /usr/local/.ghcup /opt/hostedtoolcache/CodeQL /usr/share/swift && cargo nextest run --all-targets --no-fail-fast"
},
"prettier": "@modrinth/tooling-config/labrinth.prettier.config.cjs"
diff --git a/apps/labrinth/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs
index f810ca773c..8cd4a72851 100644
--- a/apps/labrinth/src/auth/checks.rs
+++ b/apps/labrinth/src/auth/checks.rs
@@ -5,7 +5,7 @@ use crate::database::models::version_item::VersionQueryResult;
use crate::database::redis::RedisPool;
use crate::database::{DBProject, DBVersion, models};
use crate::models::users::User;
-use crate::routes::ApiError;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
use itertools::Itertools;
use sqlx::PgPool;
@@ -90,7 +90,7 @@ pub async fn filter_visible_project_ids(
user_option: &Option,
pool: &PgPool,
hide_unlisted: bool,
-) -> Result, ApiError> {
+) -> Result, ApiError> {
let mut return_projects = Vec::new();
let mut check_projects = Vec::new();
@@ -126,7 +126,7 @@ pub async fn filter_enlisted_projects_ids(
projects: Vec<&DBProject>,
user_option: &Option,
pool: &PgPool,
-) -> Result, ApiError> {
+) -> Result, ApiError> {
let mut return_projects = vec![];
if let Some(user) = user_option {
@@ -217,9 +217,8 @@ impl ValidateAuthorized for models::DBOAuthClient {
return if user.role.is_mod() || user.id == self.created_by.into() {
Ok(())
} else {
- Err(ApiError::CustomAuthentication(
- "You don't have sufficient permissions to interact with this OAuth application"
- .to_string(),
+ Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::InsufficientOAuthPermissions,
))
};
}
@@ -233,7 +232,7 @@ pub async fn filter_visible_version_ids(
user_option: &Option,
pool: &PgPool,
redis: &RedisPool,
-) -> Result, ApiError> {
+) -> Result, ApiError> {
let mut return_versions = Vec::new();
// First, filter out versions belonging to projects we can't see
@@ -282,7 +281,7 @@ pub async fn filter_enlisted_version_ids(
user_option: &Option,
pool: &PgPool,
redis: &RedisPool,
-) -> Result, ApiError> {
+) -> Result, ApiError> {
let mut return_versions = Vec::new();
// Get project ids of versions
diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs
index 2dc9311dca..d539175866 100644
--- a/apps/labrinth/src/auth/mod.rs
+++ b/apps/labrinth/src/auth/mod.rs
@@ -11,9 +11,10 @@ use serde::{Deserialize, Serialize};
pub use validate::{check_is_moderator_from_headers, get_user_from_headers};
use crate::file_hosting::FileHostingError;
-use crate::models::error::ApiError;
-use actix_web::HttpResponse;
+use crate::models::error::AsApiError;
use actix_web::http::StatusCode;
+use actix_web::{HttpResponse, ResponseError};
+use ariadne::i18n_enum;
use thiserror::Error;
#[derive(Error, Debug)]
@@ -50,7 +51,26 @@ pub enum AuthenticationError {
Url,
}
-impl actix_web::ResponseError for AuthenticationError {
+i18n_enum!(
+ AuthenticationError,
+ root_key: "labrinth.error.unauthorized",
+ Env(..) => "environment_error",
+ Sqlx(cause) => "database_error.unknown",
+ Database(cause) => "database_error",
+ SerDe(cause) => "invalid_input",
+ Reqwest(..) => "network_error",
+ FileHosting(..) => "file_hosting",
+ Decoding(cause) => "decoding_error",
+ Mail(transparent cause) => "mail_error",
+ InvalidCredentials! => "invalid_credentials",
+ InvalidAuthMethod! => "invalid_auth_method",
+ InvalidClientId! => "invalid_client_id",
+ DuplicateUser! => "duplicate_user",
+ SocketError! => "socket",
+ Url! => "url_error",
+);
+
+impl ResponseError for AuthenticationError {
fn status_code(&self) -> StatusCode {
match self {
AuthenticationError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
@@ -77,31 +97,7 @@ impl actix_web::ResponseError for AuthenticationError {
}
fn error_response(&self) -> HttpResponse {
- HttpResponse::build(self.status_code()).json(ApiError {
- error: self.error_name(),
- description: self.to_string(),
- })
- }
-}
-
-impl AuthenticationError {
- pub fn error_name(&self) -> &'static str {
- match self {
- AuthenticationError::Env(..) => "environment_error",
- AuthenticationError::Sqlx(..) => "database_error",
- AuthenticationError::Database(..) => "database_error",
- AuthenticationError::SerDe(..) => "invalid_input",
- AuthenticationError::Reqwest(..) => "network_error",
- AuthenticationError::InvalidCredentials => "invalid_credentials",
- AuthenticationError::Decoding(..) => "decoding_error",
- AuthenticationError::Mail(..) => "mail_error",
- AuthenticationError::InvalidAuthMethod => "invalid_auth_method",
- AuthenticationError::InvalidClientId => "invalid_client_id",
- AuthenticationError::Url => "url_error",
- AuthenticationError::FileHosting(..) => "file_hosting",
- AuthenticationError::DuplicateUser => "duplicate_user",
- AuthenticationError::SocketError => "socket",
- }
+ HttpResponse::build(self.status_code()).json(self.as_api_error())
}
}
diff --git a/apps/labrinth/src/auth/oauth/errors.rs b/apps/labrinth/src/auth/oauth/errors.rs
index 06656a52ee..2d22b33821 100644
--- a/apps/labrinth/src/auth/oauth/errors.rs
+++ b/apps/labrinth/src/auth/oauth/errors.rs
@@ -1,12 +1,14 @@
use super::ValidatedRedirectUri;
use crate::auth::AuthenticationError;
-use crate::models::error::ApiError;
+use crate::models::error::AsApiError;
use actix_web::HttpResponse;
use actix_web::http::{StatusCode, header::LOCATION};
+use ariadne::i18n::I18nEnum;
+use ariadne::i18n_enum;
use ariadne::ids::DecodingError;
#[derive(thiserror::Error, Debug)]
-#[error("{}", .error_type)]
+#[error("{error_type}")]
pub struct OAuthError {
#[source]
pub error_type: Box,
@@ -15,6 +17,8 @@ pub struct OAuthError {
pub valid_redirect_uri: Option,
}
+i18n_enum!(transparent OAuthError[error_type: OAuthErrorType]);
+
impl From for OAuthError
where
T: Into,
@@ -90,7 +94,13 @@ impl actix_web::ResponseError for OAuthError {
redirect_uri = format!(
"{}?error={}&error_description={}",
redirect_uri,
- self.error_type.error_name(),
+ self.error_type
+ .translation_id()
+ .split_once('.')
+ .map_or_else(
+ || self.error_type.translation_id(),
+ |(base, _)| base
+ ),
self.error_type,
);
@@ -102,10 +112,7 @@ impl actix_web::ResponseError for OAuthError {
.append_header((LOCATION, redirect_uri.clone()))
.body(redirect_uri)
} else {
- HttpResponse::build(self.status_code()).json(ApiError {
- error: &self.error_type.error_name(),
- description: self.error_type.to_string(),
- })
+ HttpResponse::build(self.status_code()).json(self.as_api_error())
}
}
}
@@ -114,7 +121,7 @@ impl actix_web::ResponseError for OAuthError {
pub enum OAuthErrorType {
#[error(transparent)]
AuthenticationError(#[from] AuthenticationError),
- #[error("Client {} has no redirect URIs specified", .client_id.0)]
+ #[error("Client {client_id} has no redirect URIs specified")]
ClientMissingRedirectURI {
client_id: crate::database::models::DBOAuthClientId,
},
@@ -154,6 +161,28 @@ pub enum OAuthErrorType {
AccessDenied,
}
+// The names before the first period match
+// IETF RFC 6749 4.1.2.1 (https://datatracker.ietf.org/doc/html/rfc6749#autoid-38)
+// and 5.2 (https://datatracker.ietf.org/doc/html/rfc6749#section-5.2)
+i18n_enum!(
+ OAuthErrorType,
+ root_key: "labrinth.error.oauth",
+ AuthenticationError(transparent cause) => "server_error",
+ ClientMissingRedirectURI { client_id } => "invalid_uri.none_specified",
+ RedirectUriNotConfigured(..) => "invalid_uri.none_configured",
+ FailedScopeParse(cause) => "invalid_scope.parse",
+ ScopesTooBroad! => "invalid_scope.too_broad",
+ InvalidAcceptFlowId! => "server_error.invalid_flow_id",
+ InvalidClientId(..) => "invalid_client.invalid_id",
+ MalformedId(cause) => "invalid_request.malformed_id",
+ ClientAuthenticationFailed! => "invalid_client.auth_failed",
+ InvalidAuthCode! => "invalid_grant.invalid_auth_code",
+ UnauthorizedClient! => "unauthorized_client",
+ RedirectUriChanged(..) => "invalid_request.redirect_uri_mismatch",
+ OnlySupportsAuthorizationCodeGrant(grant_type) => "invalid_grant.invalid_grant_type",
+ AccessDenied! => "access_denied",
+);
+
impl From for OAuthErrorType {
fn from(value: crate::database::models::DatabaseError) -> Self {
OAuthErrorType::AuthenticationError(value.into())
@@ -165,29 +194,3 @@ impl From for OAuthErrorType {
OAuthErrorType::AuthenticationError(value.into())
}
}
-
-impl OAuthErrorType {
- pub fn error_name(&self) -> String {
- // IETF RFC 6749 4.1.2.1 (https://datatracker.ietf.org/doc/html/rfc6749#autoid-38)
- // And 5.2 (https://datatracker.ietf.org/doc/html/rfc6749#section-5.2)
- match self {
- Self::RedirectUriNotConfigured(_)
- | Self::ClientMissingRedirectURI { client_id: _ } => "invalid_uri",
- Self::AuthenticationError(_) | Self::InvalidAcceptFlowId => {
- "server_error"
- }
- Self::RedirectUriChanged(_) | Self::MalformedId(_) => {
- "invalid_request"
- }
- Self::FailedScopeParse(_) | Self::ScopesTooBroad => "invalid_scope",
- Self::InvalidClientId(_) | Self::ClientAuthenticationFailed => {
- "invalid_client"
- }
- Self::InvalidAuthCode
- | Self::OnlySupportsAuthorizationCodeGrant(_) => "invalid_grant",
- Self::UnauthorizedClient => "unauthorized_client",
- Self::AccessDenied => "access_denied",
- }
- .to_string()
- }
-}
diff --git a/apps/labrinth/src/auth/templates/mod.rs b/apps/labrinth/src/auth/templates/mod.rs
index f4e3458784..925b1ad92c 100644
--- a/apps/labrinth/src/auth/templates/mod.rs
+++ b/apps/labrinth/src/auth/templates/mod.rs
@@ -46,7 +46,7 @@ impl ErrorPage {
}
}
-impl actix_web::ResponseError for ErrorPage {
+impl ResponseError for ErrorPage {
fn status_code(&self) -> StatusCode {
self.code
}
diff --git a/apps/labrinth/src/clickhouse/fetch.rs b/apps/labrinth/src/clickhouse/fetch.rs
index b0245075b8..7a54149acc 100644
--- a/apps/labrinth/src/clickhouse/fetch.rs
+++ b/apps/labrinth/src/clickhouse/fetch.rs
@@ -1,6 +1,7 @@
use std::sync::Arc;
-use crate::{models::ids::ProjectId, routes::ApiError};
+use crate::models::ids::ProjectId;
+use crate::routes::error::ApiError;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@@ -61,7 +62,7 @@ pub async fn fetch_views(
let query = client
.query(
"
- SELECT
+ SELECT
toUnixTimestamp(toStartOfInterval(recorded, toIntervalMinute(?))) AS time,
project_id AS id,
count(1) AS total
@@ -91,7 +92,7 @@ pub async fn fetch_downloads(
let query = client
.query(
"
- SELECT
+ SELECT
toUnixTimestamp(toStartOfInterval(recorded, toIntervalMinute(?))) AS time,
project_id as id,
count(1) AS total
diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs
index 483f6841e5..db59e6f4b7 100644
--- a/apps/labrinth/src/database/models/ids.rs
+++ b/apps/labrinth/src/database/models/ids.rs
@@ -15,6 +15,7 @@ use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
use serde::{Deserialize, Serialize};
use sqlx::sqlx_macros::Type;
+use std::fmt::{Display, Formatter};
const ID_RETRY_COUNT: usize = 20;
@@ -110,6 +111,12 @@ macro_rules! impl_db_id_interface {
}
}
+ impl Display for $db_id_struct {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ Display::fmt(&$id_struct(self.0 as u64), f)
+ }
+ }
+
$(
generate_ids!(
$generator_function,
diff --git a/apps/labrinth/src/database/models/legacy_loader_fields.rs b/apps/labrinth/src/database/models/legacy_loader_fields.rs
index b2f8a5b2cd..6559d769a1 100644
--- a/apps/labrinth/src/database/models/legacy_loader_fields.rs
+++ b/apps/labrinth/src/database/models/legacy_loader_fields.rs
@@ -12,7 +12,7 @@ use serde_json::json;
use crate::database::redis::RedisPool;
use super::{
- DatabaseError, LoaderFieldEnumValueId,
+ DatabaseError, LoaderFieldEnumValueId, SchemaError,
loader_fields::{
LoaderFieldEnum, LoaderFieldEnumValue, VersionField, VersionFieldValue,
},
@@ -49,11 +49,7 @@ impl MinecraftGameVersion {
let game_version_enum =
LoaderFieldEnum::get(Self::FIELD_NAME, &mut *exec, redis)
.await?
- .ok_or_else(|| {
- DatabaseError::SchemaError(
- "Could not find game version enum.".to_string(),
- )
- })?;
+ .ok_or(SchemaError::MissingGameVersionEnum)?;
let game_version_enum_values =
LoaderFieldEnumValue::list(game_version_enum.id, &mut *exec, redis)
.await?;
@@ -81,16 +77,16 @@ impl MinecraftGameVersion {
// Tries to create a MinecraftGameVersion from a VersionField
// Clones on success
pub fn try_from_version_field(
- version_field: &VersionField,
+ version_field: VersionField,
) -> Result, DatabaseError> {
if version_field.field_name != Self::FIELD_NAME {
- return Err(DatabaseError::SchemaError(format!(
- "Field name {} is not {}",
+ return Err(SchemaError::FieldNameMismatch(
version_field.field_name,
- Self::FIELD_NAME
- )));
+ Self::FIELD_NAME,
+ )
+ .into());
}
- let game_versions = match version_field.clone() {
+ let game_versions = match version_field {
VersionField {
value: VersionFieldValue::ArrayEnum(_, values),
..
@@ -102,9 +98,10 @@ impl MinecraftGameVersion {
vec![Self::from_enum_value(value)]
}
_ => {
- return Err(DatabaseError::SchemaError(format!(
- "Game version requires field value to be an enum: {version_field:?}"
- )));
+ return Err(SchemaError::GameVersionFieldNotEnum(Box::new(
+ version_field,
+ ))
+ .into());
}
};
Ok(game_versions)
@@ -185,9 +182,7 @@ impl<'a> MinecraftGameVersionBuilder<'a> {
let game_versions_enum =
LoaderFieldEnum::get("game_versions", exec, redis)
.await?
- .ok_or(DatabaseError::SchemaError(
- "Missing loaders field: 'game_versions'".to_string(),
- ))?;
+ .ok_or(SchemaError::MissingGameVersionEnum)?;
// Get enum id for game versions
let metadata = json!({
@@ -205,7 +200,7 @@ impl<'a> MinecraftGameVersionBuilder<'a> {
ON CONFLICT (enum_id, value) DO UPDATE
SET metadata = jsonb_set(
COALESCE(loader_field_enum_values.metadata, $4),
- '{type}',
+ '{type}',
COALESCE($4->'type', loader_field_enum_values.metadata->'type')
),
created = COALESCE($3, loader_field_enum_values.created)
diff --git a/apps/labrinth/src/database/models/loader_fields.rs b/apps/labrinth/src/database/models/loader_fields.rs
index 32f836c886..33f151e6b0 100644
--- a/apps/labrinth/src/database/models/loader_fields.rs
+++ b/apps/labrinth/src/database/models/loader_fields.rs
@@ -1,12 +1,14 @@
use std::collections::HashMap;
use std::hash::Hasher;
-use super::DatabaseError;
use super::ids::*;
+use super::{DatabaseError, SchemaError};
use crate::database::redis::RedisPool;
+use ariadne::i18n_enum;
use chrono::DateTime;
use chrono::Utc;
use dashmap::DashMap;
+use derive_more::Display;
use futures::TryStreamExt;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
@@ -203,7 +205,7 @@ pub struct LoaderField {
pub max_val: Option,
}
-#[derive(Clone, Serialize, Deserialize, Debug)]
+#[derive(Copy, Clone, Serialize, Deserialize, Debug)]
pub enum LoaderFieldType {
Integer,
Text,
@@ -214,6 +216,7 @@ pub enum LoaderFieldType {
ArrayEnum(LoaderFieldEnumId),
ArrayBoolean,
}
+
impl LoaderFieldType {
pub fn build(
field_type_name: &str,
@@ -291,13 +294,17 @@ impl std::hash::Hash for LoaderFieldEnumValue {
}
}
-#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Hash)]
+#[derive(
+ Clone, Serialize, Deserialize, Debug, Display, PartialEq, Eq, Hash,
+)]
+#[display("{field_name}: {}", value.field_type().to_str())]
pub struct VersionField {
pub version_id: DBVersionId,
pub field_id: LoaderFieldId,
pub field_name: String,
pub value: VersionFieldValue,
}
+
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Hash)]
pub enum VersionFieldValue {
Integer(i32),
@@ -310,6 +317,25 @@ pub enum VersionFieldValue {
ArrayBoolean(Vec),
}
+impl VersionFieldValue {
+ pub fn field_type(&self) -> LoaderFieldType {
+ match self {
+ VersionFieldValue::Integer(_) => LoaderFieldType::Integer,
+ VersionFieldValue::Text(_) => LoaderFieldType::Text,
+ VersionFieldValue::Enum(enum_id, _) => {
+ LoaderFieldType::Enum(*enum_id)
+ }
+ VersionFieldValue::Boolean(_) => LoaderFieldType::Boolean,
+ VersionFieldValue::ArrayInteger(_) => LoaderFieldType::ArrayInteger,
+ VersionFieldValue::ArrayText(_) => LoaderFieldType::ArrayText,
+ VersionFieldValue::ArrayEnum(enum_id, _) => {
+ LoaderFieldType::ArrayEnum(*enum_id)
+ }
+ VersionFieldValue::ArrayBoolean(_) => LoaderFieldType::ArrayBoolean,
+ }
+ }
+}
+
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct QueryVersionField {
pub version_id: DBVersionId,
@@ -784,7 +810,7 @@ impl VersionField {
loader_field: LoaderField,
value: serde_json::Value,
enum_variants: Vec,
- ) -> Result {
+ ) -> Result {
let value =
VersionFieldValue::parse(&loader_field, value, enum_variants)?;
@@ -804,20 +830,20 @@ impl VersionField {
if let Some(min) = loader_field.min_val
&& count < min
{
- return Err(format!(
- "Provided value '{v}' for {field_name} is less than the minimum of {min}",
- v = serde_json::to_string(&value).unwrap_or_default(),
- field_name = loader_field.field,
+ return Err(VersionFieldParseError::ValueLessThanMinimum(
+ serde_json::to_value(value).unwrap_or_default(),
+ loader_field.field,
+ min,
));
}
if let Some(max) = loader_field.max_val
&& count > max
{
- return Err(format!(
- "Provided value '{v}' for {field_name} is greater than the maximum of {max}",
- v = serde_json::to_string(&value).unwrap_or_default(),
- field_name = loader_field.field,
+ return Err(VersionFieldParseError::ValueGreaterThanMaximum(
+ serde_json::to_value(value).unwrap_or_default(),
+ loader_field.field,
+ max,
));
}
}
@@ -941,15 +967,16 @@ impl VersionFieldValue {
loader_field: &LoaderField,
value: serde_json::Value,
enum_array: Vec,
- ) -> Result {
+ ) -> Result {
let field_name = &loader_field.field;
let field_type = &loader_field.field_type;
let error_value = value.clone();
- let incorrect_type_error = |field_type: &str| {
- format!(
- "Provided value '{v}' for {field_name} could not be parsed to {field_type} ",
- v = serde_json::to_string(&error_value).unwrap_or_default()
+ let incorrect_type_error = |field_type| {
+ VersionFieldParseError::ParseFailure(
+ error_value,
+ field_name.to_string(),
+ field_type,
)
};
@@ -995,8 +1022,9 @@ impl VersionFieldValue {
{
ev
} else {
- return Err(format!(
- "Provided value '{enum_value}' is not a valid variant for {field_name}"
+ return Err(VersionFieldParseError::InvalidEnumVariant(
+ serde_json::Value::String(enum_value.to_string()),
+ field_name.to_string(),
));
}
}),
@@ -1013,9 +1041,12 @@ impl VersionFieldValue {
{
enum_values.push(ev.clone());
} else {
- return Err(format!(
- "Provided value '{av}' is not a valid variant for {field_name}"
- ));
+ return Err(
+ VersionFieldParseError::InvalidEnumVariant(
+ serde_json::Value::String(av),
+ field_name.to_string(),
+ ),
+ );
}
}
enum_values
@@ -1038,16 +1069,13 @@ impl VersionFieldValue {
| LoaderFieldType::Enum(_) => {
let mut fields = Self::build_many(field_type, qvfs, qlfev)?;
if fields.len() > 1 {
- return Err(DatabaseError::SchemaError(format!(
- "Multiple fields for field {}",
- field_type.to_str()
- )));
+ return Err(SchemaError::MultipleFields(
+ field_type.to_str(),
+ )
+ .into());
}
fields.pop().ok_or_else(|| {
- DatabaseError::SchemaError(format!(
- "No version fields for field {}",
- field_type.to_str()
- ))
+ SchemaError::NoVersionFields(field_type.to_str()).into()
})
}
LoaderFieldType::ArrayInteger
@@ -1056,10 +1084,7 @@ impl VersionFieldValue {
| LoaderFieldType::ArrayEnum(_) => {
let fields = Self::build_many(field_type, qvfs, qlfev)?;
Ok(fields.into_iter().next().ok_or_else(|| {
- DatabaseError::SchemaError(format!(
- "No version fields for field {}",
- field_type.to_str()
- ))
+ SchemaError::NoVersionFields(field_type.to_str())
})?)
}
}
@@ -1076,11 +1101,6 @@ impl VersionFieldValue {
qlfev: &[QueryLoaderFieldEnumValue],
) -> Result, DatabaseError> {
let field_name = field_type.to_str();
- let did_not_exist_error = |field_name: &str, desired_field: &str| {
- DatabaseError::SchemaError(format!(
- "Field name {desired_field} for field {field_name} in does not exist"
- ))
- };
// Check errors- version_id must all be the same
// If the field type is a non-array, then the reason for multiple version ids is that there are multiple versions being aggregated, and those version ids are contained within.
@@ -1094,81 +1114,87 @@ impl VersionFieldValue {
.unwrap_or(DBVersionId(0));
if qvfs.iter().map(|qvf| qvf.field_id).unique().count() > 1 {
- return Err(DatabaseError::SchemaError(format!(
- "Multiple field ids for field {field_name}"
- )));
+ return Err(SchemaError::MultipleIdsForField(field_name).into());
}
- let mut value = match field_type {
- // Singleton fields
- // If there are multiple, we assume multiple versions are being concatenated
- LoaderFieldType::Integer => {
- qvfs.into_iter()
+ let mut value =
+ match field_type {
+ // Singleton fields
+ // If there are multiple, we assume multiple versions are being concatenated
+ LoaderFieldType::Integer => qvfs
+ .into_iter()
.map(|qvf| {
Ok((
qvf.version_id,
VersionFieldValue::Integer(qvf.int_value.ok_or(
- did_not_exist_error(field_name, "int_value"),
+ SchemaError::FieldNameDoesNotExist(
+ "int_value",
+ field_name,
+ ),
)?),
))
})
.collect::,
DatabaseError,
- >>()?
- }
- LoaderFieldType::Text => {
- qvfs.into_iter()
+ >>()?,
+ LoaderFieldType::Text => qvfs
+ .into_iter()
.map(|qvf| {
Ok((
qvf.version_id,
VersionFieldValue::Text(qvf.string_value.ok_or(
- did_not_exist_error(field_name, "string_value"),
+ SchemaError::FieldNameDoesNotExist(
+ "string_value",
+ field_name,
+ ),
)?),
))
})
.collect::,
DatabaseError,
- >>()?
- }
- LoaderFieldType::Boolean => {
- qvfs.into_iter()
+ >>()?,
+ LoaderFieldType::Boolean => qvfs
+ .into_iter()
.map(|qvf| {
Ok((
qvf.version_id,
VersionFieldValue::Boolean(
- qvf.int_value.ok_or(did_not_exist_error(
- field_name,
- "int_value",
- ))? != 0,
+ qvf.int_value.ok_or(
+ SchemaError::FieldNameDoesNotExist(
+ "int_value",
+ field_name,
+ ),
+ )? != 0,
),
))
})
.collect::,
DatabaseError,
- >>()?
- }
- LoaderFieldType::Enum(id) => {
- qvfs.into_iter()
+ >>()?,
+ LoaderFieldType::Enum(id) => qvfs
+ .into_iter()
.map(|qvf| {
Ok((
qvf.version_id,
VersionFieldValue::Enum(*id, {
let enum_id = qvf.enum_value.ok_or(
- did_not_exist_error(
- field_name,
+ SchemaError::FieldNameDoesNotExist(
"enum_value",
+ field_name,
),
)?;
let lfev = qlfev
.iter()
.find(|x| x.id == enum_id)
- .ok_or(did_not_exist_error(
- field_name,
- "enum_value",
- ))?;
+ .ok_or(
+ SchemaError::FieldNameDoesNotExist(
+ "enum_value",
+ field_name,
+ ),
+ )?;
LoaderFieldEnumValue {
id: lfev.id,
enum_id: lfev.enum_id,
@@ -1186,84 +1212,94 @@ impl VersionFieldValue {
.collect::,
DatabaseError,
- >>()?
- }
-
- // Array fields
- // We concatenate into one array
- LoaderFieldType::ArrayInteger => vec![(
- version_id,
- VersionFieldValue::ArrayInteger(
- qvfs.into_iter()
- .map(|qvf| {
- qvf.int_value.ok_or(did_not_exist_error(
- field_name,
- "int_value",
- ))
- })
- .collect::>()?,
- ),
- )],
- LoaderFieldType::ArrayText => vec![(
- version_id,
- VersionFieldValue::ArrayText(
- qvfs.into_iter()
- .map(|qvf| {
- qvf.string_value.ok_or(did_not_exist_error(
- field_name,
- "string_value",
- ))
- })
- .collect::>()?,
- ),
- )],
- LoaderFieldType::ArrayBoolean => vec![(
- version_id,
- VersionFieldValue::ArrayBoolean(
- qvfs.into_iter()
- .map(|qvf| {
- Ok::(
- qvf.int_value.ok_or(did_not_exist_error(
- field_name,
- "int_value",
- ))? != 0,
- )
- })
- .collect::>()?,
- ),
- )],
- LoaderFieldType::ArrayEnum(id) => vec![(
- version_id,
- VersionFieldValue::ArrayEnum(
- *id,
- qvfs.into_iter()
- .map(|qvf| {
- let enum_id = qvf.enum_value.ok_or(
- did_not_exist_error(field_name, "enum_value"),
- )?;
- let lfev = qlfev
- .iter()
- .find(|x| x.id == enum_id)
- .ok_or(did_not_exist_error(
- field_name,
- "enum_value",
- ))?;
- Ok::<_, DatabaseError>(LoaderFieldEnumValue {
- id: lfev.id,
- enum_id: lfev.enum_id,
- value: lfev.value.clone(),
- ordering: lfev.ordering,
- created: lfev.created,
- metadata: lfev
- .metadata
- .clone()
- .unwrap_or_default(),
+ >>()?,
+
+ // Array fields
+ // We concatenate into one array
+ LoaderFieldType::ArrayInteger => vec![(
+ version_id,
+ VersionFieldValue::ArrayInteger(
+ qvfs.into_iter()
+ .map(|qvf| {
+ qvf.int_value.ok_or(
+ SchemaError::FieldNameDoesNotExist(
+ "int_value",
+ field_name,
+ ),
+ )
})
- })
- .collect::>()?,
- ),
- )],
- };
+ .collect::>()?,
+ ),
+ )],
+ LoaderFieldType::ArrayText => vec![(
+ version_id,
+ VersionFieldValue::ArrayText(
+ qvfs.into_iter()
+ .map(|qvf| {
+ qvf.string_value.ok_or(
+ SchemaError::FieldNameDoesNotExist(
+ "string_value",
+ field_name,
+ ),
+ )
+ })
+ .collect::>()?,
+ ),
+ )],
+ LoaderFieldType::ArrayBoolean => vec![(
+ version_id,
+ VersionFieldValue::ArrayBoolean(
+ qvfs.into_iter()
+ .map(|qvf| {
+ Ok::(
+ qvf.int_value.ok_or(
+ SchemaError::FieldNameDoesNotExist(
+ "int_value",
+ field_name,
+ ),
+ )? != 0,
+ )
+ })
+ .collect::>()?,
+ ),
+ )],
+ LoaderFieldType::ArrayEnum(id) => vec![(
+ version_id,
+ VersionFieldValue::ArrayEnum(
+ *id,
+ qvfs.into_iter()
+ .map(|qvf| {
+ let enum_id = qvf.enum_value.ok_or(
+ SchemaError::FieldNameDoesNotExist(
+ "enum_value",
+ field_name,
+ ),
+ )?;
+ let lfev = qlfev
+ .iter()
+ .find(|x| x.id == enum_id)
+ .ok_or(
+ SchemaError::FieldNameDoesNotExist(
+ "enum_value",
+ field_name,
+ ),
+ )?;
+ Ok::<_, DatabaseError>(LoaderFieldEnumValue {
+ id: lfev.id,
+ enum_id: lfev.enum_id,
+ value: lfev.value.clone(),
+ ordering: lfev.ordering,
+ created: lfev.created,
+ metadata: lfev
+ .metadata
+ .clone()
+ .unwrap_or_default(),
+ })
+ })
+ .collect::>()?,
+ ),
+ )],
+ };
// Sort arrayenums by ordering, then by created
for (_, v) in &mut value {
@@ -1351,3 +1387,24 @@ impl VersionFieldValue {
}
}
}
+
+#[derive(Debug, thiserror::Error)]
+pub enum VersionFieldParseError {
+ #[error("Provided value {0} for {1} is less than the minimum of {2}")]
+ ValueLessThanMinimum(serde_json::Value, String, i32),
+ #[error("Provided value {0} for {1} is greater than the maximum of {2}")]
+ ValueGreaterThanMaximum(serde_json::Value, String, i32),
+ #[error("Provided value {0} for {1} could not be parsed to {2}")]
+ ParseFailure(serde_json::Value, String, &'static str),
+ #[error("Provided value {0} is not a valid variant for {1}")]
+ InvalidEnumVariant(serde_json::Value, String),
+}
+
+i18n_enum!(
+ VersionFieldParseError,
+ root_key: "labrinth.error.version_field_parse",
+ ValueLessThanMinimum(value, field_name, minimum) => "value_less_than_minimum",
+ ValueGreaterThanMaximum(value, field_name, maximum) => "value_greater_than_maximum",
+ ParseFailure(value, field_name, field_type) => "parse_failure",
+ InvalidEnumVariant(value, field_name) => "invalid_enum_variant",
+);
diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs
index 25d77ad949..c92bb2576c 100644
--- a/apps/labrinth/src/database/models/mod.rs
+++ b/apps/labrinth/src/database/models/mod.rs
@@ -1,3 +1,4 @@
+use ariadne::i18n_enum;
use thiserror::Error;
pub mod affiliate_code_item;
@@ -61,7 +62,49 @@ pub enum DatabaseError {
#[error("Error while serializing with the cache: {0}")]
SerdeCacheError(#[from] serde_json::Error),
#[error("Schema error: {0}")]
- SchemaError(String),
+ SchemaError(#[from] SchemaError),
#[error("Timeout when waiting for cache subscriber")]
CacheTimeout,
}
+
+i18n_enum!(
+ DatabaseError,
+ root_key: "labrinth.error.database",
+ Database(cause) => "sqlx",
+ RandomId! => "random_id",
+ CacheError(cause) => "cache",
+ RedisPool(cause) => "redis_pool",
+ SerdeCacheError(cause) => "cache_serialization",
+ SchemaError(cause) => "schema",
+ CacheTimeout! => "cache_timeout",
+);
+
+#[derive(Error, Debug)]
+pub enum SchemaError {
+ #[error("Could not find game version enum.")]
+ MissingGameVersionEnum,
+ #[error("Field name {0} is not {1}")]
+ FieldNameMismatch(String, &'static str),
+ #[error("Game version requires field value to be an enum: {0}")]
+ GameVersionFieldNotEnum(Box),
+ #[error("Multiple fields for field {0}")]
+ MultipleFields(&'static str),
+ #[error("No version fields for field {0}")]
+ NoVersionFields(&'static str),
+ #[error("Multiple field ids for field {0}")]
+ MultipleIdsForField(&'static str),
+ #[error("Field name {0} for field {1} in does not exist")]
+ FieldNameDoesNotExist(&'static str, &'static str),
+}
+
+i18n_enum!(
+ SchemaError,
+ root_key: "labrinth.error.database.schema",
+ MissingGameVersionEnum! => "missing_game_version_enum",
+ FieldNameMismatch(expected, actual) => "field_name_mismatch",
+ GameVersionFieldNotEnum(field) => "game_version_field_not_enum",
+ MultipleFields(field) => "multiple_fields",
+ NoVersionFields(field) => "no_version_fields",
+ MultipleIdsForField(field) => "multiple_ids_for_field",
+ FieldNameDoesNotExist(field_name, field) => "field_name_does_not_exist",
+);
diff --git a/apps/labrinth/src/file_hosting/mod.rs b/apps/labrinth/src/file_hosting/mod.rs
index 7de0ff6a9e..7b7f455d76 100644
--- a/apps/labrinth/src/file_hosting/mod.rs
+++ b/apps/labrinth/src/file_hosting/mod.rs
@@ -4,20 +4,51 @@ use thiserror::Error;
mod mock;
mod s3_host;
+use ariadne::i18n_enum;
use bytes::Bytes;
+use derive_more::Display;
pub use mock::MockHost;
pub use s3_host::{S3BucketConfig, S3Host};
#[derive(Error, Debug)]
pub enum FileHostingError {
- #[error("S3 error when {0}: {1}")]
- S3Error(&'static str, s3::error::S3Error),
+ #[error("{0}: {1}")]
+ S3Error(S3ErrorAction, s3::error::S3Error),
#[error("File system error in file hosting: {0}")]
FileSystemError(#[from] std::io::Error),
#[error("Invalid Filename")]
InvalidFilename,
}
+i18n_enum!(
+ FileHostingError,
+ root_key: "labrinth.error.file_hosting_error",
+ S3Error(action, cause) => "s3",
+ FileSystemError(cause) => "file_system",
+ InvalidFilename! => "invalid_filename",
+);
+
+#[derive(Copy, Clone, Debug, Display)]
+pub enum S3ErrorAction {
+ #[display("S3 error when creating bucket instance")]
+ CreatingBucketInstance,
+ #[display("S3 error when uploading file")]
+ UploadingFile,
+ #[display("S3 error when generating presigned URL")]
+ GeneratingPresignedUrl,
+ #[display("S3 error when deleting file")]
+ DeletingFile,
+}
+
+i18n_enum!(
+ S3ErrorAction,
+ root_key: "labrinth.error.file_hosting_error.s3",
+ CreatingBucketInstance! => "creating_bucket_instance",
+ UploadingFile! => "uploading_file",
+ GeneratingPresignedUrl! => "generating_presigned_url",
+ DeletingFile! => "deleting_file",
+);
+
#[derive(Debug, Clone)]
pub struct UploadFileData {
pub file_name: String,
diff --git a/apps/labrinth/src/file_hosting/s3_host.rs b/apps/labrinth/src/file_hosting/s3_host.rs
index a1a7c02dfc..b7e03209ce 100644
--- a/apps/labrinth/src/file_hosting/s3_host.rs
+++ b/apps/labrinth/src/file_hosting/s3_host.rs
@@ -1,6 +1,6 @@
use crate::file_hosting::{
DeleteFileData, FileHost, FileHostPublicity, FileHostingError,
- UploadFileData,
+ S3ErrorAction, UploadFileData,
};
use async_trait::async_trait;
use bytes::Bytes;
@@ -51,7 +51,10 @@ impl S3Host {
},
)
.map_err(|e| {
- FileHostingError::S3Error("creating Bucket instance", e)
+ FileHostingError::S3Error(
+ S3ErrorAction::CreatingBucketInstance,
+ e,
+ )
})?;
bucket.name = config.name;
@@ -97,7 +100,9 @@ impl FileHost for S3Host {
content_type,
)
.await
- .map_err(|e| FileHostingError::S3Error("uploading file", e))?;
+ .map_err(|e| {
+ FileHostingError::S3Error(S3ErrorAction::UploadingFile, e)
+ })?;
Ok(UploadFileData {
file_name: file_name.to_string(),
@@ -121,7 +126,10 @@ impl FileHost for S3Host {
.presign_get(format!("/{file_name}"), expiry_secs, None)
.await
.map_err(|e| {
- FileHostingError::S3Error("generating presigned URL", e)
+ FileHostingError::S3Error(
+ S3ErrorAction::GeneratingPresignedUrl,
+ e,
+ )
})?;
Ok(url)
}
@@ -134,7 +142,9 @@ impl FileHost for S3Host {
self.get_bucket(file_publicity)
.delete_object(format!("/{file_name}"))
.await
- .map_err(|e| FileHostingError::S3Error("deleting file", e))?;
+ .map_err(|e| {
+ FileHostingError::S3Error(S3ErrorAction::DeletingFile, e)
+ })?;
Ok(DeleteFileData {
file_name: file_name.to_string(),
diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs
index f58c5aaf2c..32542154be 100644
--- a/apps/labrinth/src/lib.rs
+++ b/apps/labrinth/src/lib.rs
@@ -12,6 +12,7 @@ use tracing::{info, warn};
extern crate clickhouse as clickhouse_crate;
use clickhouse_crate::Client;
+use routes::error;
use util::cors::default_cors;
use crate::background_task::update_versions;
@@ -296,16 +297,16 @@ pub fn app_config(
labrinth_config: LabrinthConfig,
) {
cfg.app_data(web::FormConfig::default().error_handler(|err, _req| {
- routes::ApiError::Validation(err.to_string()).into()
+ error::ApiError::Validation(err.to_string()).into()
}))
.app_data(web::PathConfig::default().error_handler(|err, _req| {
- routes::ApiError::Validation(err.to_string()).into()
+ error::ApiError::Validation(err.to_string()).into()
}))
.app_data(web::QueryConfig::default().error_handler(|err, _req| {
- routes::ApiError::Validation(err.to_string()).into()
+ error::ApiError::Validation(err.to_string()).into()
}))
.app_data(web::JsonConfig::default().error_handler(|err, _req| {
- routes::ApiError::Validation(err.to_string()).into()
+ error::ApiError::Validation(err.to_string()).into()
}))
.app_data(web::Data::new(labrinth_config.redis_pool.clone()))
.app_data(web::Data::new(labrinth_config.pool.clone()))
diff --git a/apps/labrinth/src/models/error.rs b/apps/labrinth/src/models/error.rs
index 28f737c16c..48934c60b7 100644
--- a/apps/labrinth/src/models/error.rs
+++ b/apps/labrinth/src/models/error.rs
@@ -1,8 +1,31 @@
+use ariadne::i18n::{I18nEnum, TranslationData};
use serde::{Deserialize, Serialize};
+use std::fmt::Display;
/// An error returned by the API
#[derive(Serialize, Deserialize)]
-pub struct ApiError<'a> {
- pub error: &'a str,
+pub struct ApiError {
+ pub error: &'static str,
pub description: String,
+ pub translatable_error: TranslationData,
+}
+
+pub trait AsApiError {
+ fn as_api_error(&self) -> ApiError;
+}
+
+impl AsApiError for T
+where
+ T: I18nEnum + Display,
+{
+ fn as_api_error(&self) -> ApiError {
+ let translation_id = self.translation_id();
+ ApiError {
+ error: translation_id
+ .split_once('.')
+ .map_or(translation_id, |(base, _)| base),
+ description: self.to_string(),
+ translatable_error: self.translation_data(),
+ }
+ }
}
diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs
index b74ab8c572..d6d61621a3 100644
--- a/apps/labrinth/src/models/v3/notifications.rs
+++ b/apps/labrinth/src/models/v3/notifications.rs
@@ -7,9 +7,12 @@ use crate::models::ids::{
VersionId,
};
use crate::models::projects::ProjectStatus;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
+use ariadne::i18n::{I18nEnum, TranslationData};
+use ariadne::i18n_enum;
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
+use derive_more::Display;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
@@ -21,75 +24,106 @@ pub struct Notification {
pub body: NotificationBody,
pub name: String,
+ pub translatable_name: TranslationData,
pub text: String,
+ pub translatable_text: TranslationData,
pub link: String,
pub actions: Vec,
}
-#[derive(Serialize, Deserialize, Clone, Copy, Eq, PartialEq)]
+#[derive(Serialize, Deserialize, Clone, Copy, Eq, PartialEq, Display)]
#[serde(rename_all = "snake_case")]
pub enum NotificationType {
// If adding a notification type, add a variant in `NotificationBody` of the same name!
+ #[display("A project you follow has been updated!")]
ProjectUpdate,
+ #[display("You have been invited to join a team!")]
TeamInvite,
+ #[display("You have been invited to join an organization!")]
OrganizationInvite,
+ #[display("Project status has changed")]
StatusChange,
+ #[display("A moderator has sent you a message!")]
ModeratorMessage,
+ // The notifications from here to down below are listed with messages for completeness' sake,
+ // though they should never be sent via site notifications. This should be disabled via database
+ // options. Messages should be reviewed and worded better if we want to distribute these notifications
+ // via the site.
+ #[display("LEGACY MARKDOWN NOTIFICATION")]
LegacyMarkdown,
+ #[display("Password reset requested")]
ResetPassword,
+ #[display("Verify your email")]
VerifyEmail,
+ #[display("Auth provider added")]
AuthProviderAdded,
+ #[display("Auth provider removed")]
AuthProviderRemoved,
+ #[display("Two-factor authentication enabled")]
TwoFactorEnabled,
+ #[display("Two-factor authentication removed")]
TwoFactorRemoved,
+ #[display("Password changed")]
PasswordChanged,
+ #[display("Password removed")]
PasswordRemoved,
+ #[display("Email changed")]
EmailChanged,
+ #[display("Payment failed")]
PaymentFailed,
+ #[display("New personal access token created")]
PatCreated,
+ #[display("New message in moderation thread")]
ModerationMessageReceived,
+ #[display("Report status updated")]
ReportStatusUpdated,
+ #[display("Report submitted")]
ReportSubmitted,
+ #[display("Project approved")]
ProjectStatusApproved,
+ #[display("Project status updated")]
ProjectStatusNeutral,
+ #[display("Project ownership transferred")]
ProjectTransferred,
+ #[display("Payout available")]
PayoutAvailable,
+ #[display("")]
Unknown,
}
+i18n_enum!(
+ NotificationType,
+ root_key: "labrinth.notification.type",
+ ProjectUpdate! => "project_update",
+ TeamInvite! => "team_invite",
+ OrganizationInvite! => "organization_invite",
+ StatusChange! => "status_change",
+ ModeratorMessage! => "moderator_message",
+ LegacyMarkdown! => "legacy_markdown",
+ ResetPassword! => "reset_password",
+ VerifyEmail! => "verify_email",
+ AuthProviderAdded! => "auth_provider_added",
+ AuthProviderRemoved! => "auth_provider_removed",
+ TwoFactorEnabled! => "two_factor_enabled",
+ TwoFactorRemoved! => "two_factor_removed",
+ PasswordChanged! => "password_changed",
+ PasswordRemoved! => "password_removed",
+ EmailChanged! => "email_changed",
+ PaymentFailed! => "payment_failed",
+ PatCreated! => "pat_created",
+ ModerationMessageReceived! => "moderation_message_received",
+ ReportStatusUpdated! => "report_status_updated",
+ ReportSubmitted! => "report_submitted",
+ ProjectStatusApproved! => "project_status_approved",
+ ProjectStatusNeutral! => "project_status_neutral",
+ ProjectTransferred! => "project_transferred",
+ PayoutAvailable! => "payout_available",
+ Unknown! => "unknown",
+);
+
impl NotificationType {
pub fn as_str(self) -> &'static str {
- match self {
- NotificationType::ProjectUpdate => "project_update",
- NotificationType::TeamInvite => "team_invite",
- NotificationType::OrganizationInvite => "organization_invite",
- NotificationType::StatusChange => "status_change",
- NotificationType::ModeratorMessage => "moderator_message",
- NotificationType::LegacyMarkdown => "legacy_markdown",
- NotificationType::ResetPassword => "reset_password",
- NotificationType::VerifyEmail => "verify_email",
- NotificationType::AuthProviderAdded => "auth_provider_added",
- NotificationType::AuthProviderRemoved => "auth_provider_removed",
- NotificationType::TwoFactorEnabled => "two_factor_enabled",
- NotificationType::TwoFactorRemoved => "two_factor_removed",
- NotificationType::PasswordChanged => "password_changed",
- NotificationType::PasswordRemoved => "password_removed",
- NotificationType::EmailChanged => "email_changed",
- NotificationType::PaymentFailed => "payment_failed",
- NotificationType::PatCreated => "pat_created",
- NotificationType::ModerationMessageReceived => {
- "moderation_message_received"
- }
- NotificationType::ReportStatusUpdated => "report_status_updated",
- NotificationType::ReportSubmitted => "report_submitted",
- NotificationType::ProjectStatusApproved => {
- "project_status_approved"
- }
- NotificationType::ProjectStatusNeutral => "project_status_neutral",
- NotificationType::ProjectTransferred => "project_transferred",
- NotificationType::PayoutAvailable => "payout_available",
- NotificationType::Unknown => "unknown",
- }
+ self.translation_id()
}
pub fn from_str_or_default(s: &str) -> Self {
@@ -128,31 +162,40 @@ impl NotificationType {
}
}
-#[derive(Serialize, Deserialize, Clone)]
+#[derive(Serialize, Deserialize, Clone, Display)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum NotificationBody {
+ #[display(
+ "The project {project_id} has released a new version: {version_id}"
+ )]
ProjectUpdate {
project_id: ProjectId,
version_id: VersionId,
},
+ #[display("An invite has been sent for you to be {role} of a team")]
TeamInvite {
project_id: ProjectId,
team_id: TeamId,
invited_by: UserId,
role: String,
},
+ #[display(
+ "An invite has been sent for you to be {role} of an organization"
+ )]
OrganizationInvite {
organization_id: OrganizationId,
invited_by: UserId,
team_id: TeamId,
role: String,
},
+ #[display("Status has changed from {old_status} to {new_status}")]
StatusChange {
project_id: ProjectId,
old_status: ProjectStatus,
new_status: ProjectStatus,
},
/// This is for website notifications only. Email notifications have `ModerationMessageReceived`.
+ #[display("Click on the link to read more.")]
ModeratorMessage {
thread_id: ThreadId,
message_id: ThreadMessageId,
@@ -160,33 +203,31 @@ pub enum NotificationBody {
project_id: Option,
report_id: Option,
},
- PatCreated {
- token_name: String,
- },
+ #[display("Your personal access token '{token_name}' was created.")]
+ PatCreated { token_name: String },
/// This differs from ModeratorMessage as this notification is only for project threads and
/// email notifications, not for site notifications.
- ModerationMessageReceived {
- project_id: ProjectId,
- },
- ReportStatusUpdated {
- report_id: ReportId,
- },
- ReportSubmitted {
- report_id: ReportId,
- },
- ProjectStatusApproved {
- project_id: ProjectId,
- },
+ #[display("You have a new message in a moderation thread.")]
+ ModerationMessageReceived { project_id: ProjectId },
+ #[display("A report you are involved in has been updated.")]
+ ReportStatusUpdated { report_id: ReportId },
+ #[display("Your report was submitted successfully.")]
+ ReportSubmitted { report_id: ReportId },
+ #[display("Your project has been approved.")]
+ ProjectStatusApproved { project_id: ProjectId },
+ #[display("Your project status has been updated.")]
ProjectStatusNeutral {
project_id: ProjectId,
old_status: ProjectStatus,
new_status: ProjectStatus,
},
+ #[display("A project's ownership has been transferred.")]
ProjectTransferred {
project_id: ProjectId,
new_owner_user_id: Option,
new_owner_organization_id: Option,
},
+ #[display("{text}")]
LegacyMarkdown {
notification_type: Option,
name: String,
@@ -194,37 +235,72 @@ pub enum NotificationBody {
link: String,
actions: Vec,
},
- ResetPassword {
- flow: String,
- },
- VerifyEmail {
- flow: String,
- },
- AuthProviderAdded {
- provider: String,
- },
- AuthProviderRemoved {
- provider: String,
- },
+ #[display(
+ "You've requested to reset your password. Please check your email for a reset link."
+ )]
+ ResetPassword { flow: String },
+ // See comment above VerifyEmail in NotificationType
+ #[display(
+ "You've requested to verify your email. Please check your email for a verification link."
+ )]
+ VerifyEmail { flow: String },
+ #[display("You've added a new authentication provider to your account.")]
+ AuthProviderAdded { provider: String },
+ #[display("You've removed a authentication provider from your account.")]
+ AuthProviderRemoved { provider: String },
+ #[display("You've enabled two-factor authentication on your account.")]
TwoFactorEnabled,
+ #[display("You've removed two-factor authentication from your account.")]
TwoFactorRemoved,
+ #[display("You've changed your account password.")]
PasswordChanged,
+ #[display("You've removed your account password.")]
PasswordRemoved,
- EmailChanged {
- new_email: String,
- to_email: String,
- },
- PaymentFailed {
- amount: String,
- service: String,
- },
+ #[display("Your account email was changed.")]
+ EmailChanged { new_email: String, to_email: String },
+ #[display(
+ "A payment on your account failed. Please update your billing information."
+ )]
+ PaymentFailed { amount: String, service: String },
+ #[display("A payout is available!")]
PayoutAvailable {
date_available: DateTime,
amount: f64,
},
+ #[display("")]
Unknown,
}
+i18n_enum!(
+ NotificationBody,
+ root_key: "labrinth.notification.body",
+ ProjectUpdate { project_id, version_id } => "project_update",
+ TeamInvite { role, .. } => "team_invite",
+ OrganizationInvite { role, .. } => "organization_invite",
+ StatusChange { old_status, new_status, .. } => "status_change",
+ ModeratorMessage { .. } => "moderator_message",
+ PatCreated { token_name } => "pat_created",
+ ModerationMessageReceived { .. } => "moderation_message_received",
+ ReportStatusUpdated { .. } => "report_status_updated",
+ ReportSubmitted { .. } => "report_submitted",
+ ProjectStatusApproved { .. } => "project_status_approved",
+ ProjectStatusNeutral { .. } => "project_status_neutral",
+ ProjectTransferred { .. } => "project_transferred",
+ LegacyMarkdown { transparent text } => "legacy_markdown",
+ ResetPassword { .. } => "reset_password",
+ VerifyEmail { .. } => "verify_email",
+ AuthProviderAdded { .. } => "auth_provider_added",
+ AuthProviderRemoved { .. } => "auth_provider_removed",
+ TwoFactorEnabled! => "two_factor_enabled",
+ TwoFactorRemoved! => "two_factor_removed",
+ PasswordChanged! => "password_changed",
+ PasswordRemoved! => "password_removed",
+ EmailChanged { .. } => "email_changed",
+ PaymentFailed { .. } => "payment_failed",
+ PayoutAvailable { .. } => "payout_available",
+ Unknown! => "unknown",
+);
+
impl NotificationBody {
pub fn notification_type(&self) -> NotificationType {
match &self {
@@ -303,29 +379,20 @@ impl NotificationBody {
impl From for Notification {
fn from(notif: DBNotification) -> Self {
- let (name, text, link, actions) = {
+ let (link, actions) = {
match ¬if.body {
NotificationBody::ProjectUpdate {
project_id,
version_id,
} => (
- "A project you follow has been updated!".to_string(),
- format!(
- "The project {project_id} has released a new version: {version_id}"
- ),
format!("/project/{project_id}/version/{version_id}"),
vec![],
),
NotificationBody::TeamInvite {
project_id,
- role,
team_id,
..
} => (
- "You have been invited to join a team!".to_string(),
- format!(
- "An invite has been sent for you to be {role} of a team"
- ),
format!("/project/{project_id}"),
vec![
NotificationAction {
@@ -349,15 +416,9 @@ impl From for Notification {
),
NotificationBody::OrganizationInvite {
organization_id,
- role,
team_id,
..
} => (
- "You have been invited to join an organization!"
- .to_string(),
- format!(
- "An invite has been sent for you to be {role} of an organization"
- ),
format!("/organization/{organization_id}"),
vec![
NotificationAction {
@@ -379,27 +440,14 @@ impl From for Notification {
},
],
),
- NotificationBody::StatusChange {
- old_status,
- new_status,
- project_id,
- } => (
- "Project status has changed".to_string(),
- format!(
- "Status has changed from {} to {}",
- old_status.as_friendly_str(),
- new_status.as_friendly_str()
- ),
- format!("/project/{project_id}"),
- vec![],
- ),
+ NotificationBody::StatusChange { project_id, .. } => {
+ (format!("/project/{project_id}"), vec![])
+ }
NotificationBody::ModeratorMessage {
project_id,
report_id,
..
} => (
- "A moderator has sent you a message!".to_string(),
- "Click on the link to read more.".to_string(),
if let Some(project_id) = project_id {
format!("/project/{project_id}")
} else if let Some(report_id) = report_id {
@@ -409,136 +457,70 @@ impl From for Notification {
},
vec![],
),
- // The notifications from here to down below are listed with messages for completeness' sake,
- // though they should never be sent via site notifications. This should be disabled via database
- // options. Messages should be reviewed and worded better if we want to distribute these notifications
- // via the site.
- NotificationBody::PatCreated { token_name } => (
- "New personal access token created".to_string(),
- format!("Your personal access token '{token_name}' was created."),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::ReportStatusUpdated { .. } => (
- "Report status updated".to_string(),
- "A report you are involved in has been updated.".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::ReportSubmitted { .. } => (
- "Report submitted".to_string(),
- "Your report was submitted successfully.".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::ProjectStatusApproved { .. } => (
- "Project approved".to_string(),
- "Your project has been approved.".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::ProjectStatusNeutral { .. } => (
- "Project status updated".to_string(),
- "Your project status has been updated.".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::ProjectTransferred { .. } => (
- "Project ownership transferred".to_string(),
- "A project's ownership has been transferred.".to_string(),
- "#".to_string(),
- vec![],
- ),
+ NotificationBody::PatCreated { .. } => {
+ ("#".to_string(), vec![])
+ }
+ NotificationBody::ReportStatusUpdated { .. } => {
+ ("#".to_string(), vec![])
+ }
+ NotificationBody::ReportSubmitted { .. } => {
+ ("#".to_string(), vec![])
+ }
+ NotificationBody::ProjectStatusApproved { .. } => {
+ ("#".to_string(), vec![])
+ }
+ NotificationBody::ProjectStatusNeutral { .. } => {
+ ("#".to_string(), vec![])
+ }
+ NotificationBody::ProjectTransferred { .. } => {
+ ("#".to_string(), vec![])
+ }
// Don't expose the `flow` field
- NotificationBody::ResetPassword { .. } => (
- "Password reset requested".to_string(),
- "You've requested to reset your password. Please check your email for a reset link.".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::LegacyMarkdown {
- name,
- text,
- link,
- actions,
- ..
- } => (
- name.clone(),
- text.clone(),
- link.clone(),
- actions.clone().into_iter().collect(),
- ),
- NotificationBody::PaymentFailed { .. } => (
- "Payment failed".to_string(),
- "A payment on your account failed. Please update your billing information.".to_string(),
- "/settings/billing".to_string(),
- vec![],
- ),
- NotificationBody::VerifyEmail { .. } => (
- "Verify your email".to_string(),
- "You've requested to verify your email. Please check your email for a verification link.".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::AuthProviderAdded { .. } => (
- "Auth provider added".to_string(),
- "You've added a new authentication provider to your account.".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::AuthProviderRemoved { .. } => (
- "Auth provider removed".to_string(),
- "You've removed a authentication provider from your account.".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::TwoFactorEnabled => (
- "Two-factor authentication enabled".to_string(),
- "You've enabled two-factor authentication on your account.".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::TwoFactorRemoved => (
- "Two-factor authentication removed".to_string(),
- "You've removed two-factor authentication from your account.".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::PasswordChanged => (
- "Password changed".to_string(),
- "You've changed your account password.".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::PasswordRemoved => (
- "Password removed".to_string(),
- "You've removed your account password.".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::EmailChanged { .. } => (
- "Email changed".to_string(),
- "Your account email was changed.".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::PayoutAvailable { .. } => (
- "Payout available".to_string(),
- "A payout is available!".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::ModerationMessageReceived { .. } => (
- "New message in moderation thread".to_string(),
- "You have a new message in a moderation thread.".to_string(),
- "#".to_string(),
- vec![],
- ),
- NotificationBody::Unknown => {
- ("".to_string(), "".to_string(), "#".to_string(), vec![])
+ NotificationBody::ResetPassword { .. } => {
+ ("#".to_string(), vec![])
}
+ NotificationBody::LegacyMarkdown { link, actions, .. } => {
+ (link.clone(), actions.clone().into_iter().collect())
+ }
+ NotificationBody::PaymentFailed { .. } => {
+ ("/settings/billing".to_string(), vec![])
+ }
+ NotificationBody::VerifyEmail { .. } => {
+ ("#".to_string(), vec![])
+ }
+ NotificationBody::AuthProviderAdded { .. } => {
+ ("#".to_string(), vec![])
+ }
+ NotificationBody::AuthProviderRemoved { .. } => {
+ ("#".to_string(), vec![])
+ }
+ NotificationBody::TwoFactorEnabled => ("#".to_string(), vec![]),
+ NotificationBody::TwoFactorRemoved => ("#".to_string(), vec![]),
+ NotificationBody::PasswordChanged => ("#".to_string(), vec![]),
+ NotificationBody::PasswordRemoved => ("#".to_string(), vec![]),
+ NotificationBody::EmailChanged { .. } => {
+ ("#".to_string(), vec![])
+ }
+ NotificationBody::PayoutAvailable { .. } => {
+ ("#".to_string(), vec![])
+ }
+ NotificationBody::ModerationMessageReceived { .. } => {
+ ("#".to_string(), vec![])
+ }
+ NotificationBody::Unknown => ("#".to_string(), vec![]),
+ }
+ };
+ let (name, translatable_name) = match ¬if.body {
+ NotificationBody::LegacyMarkdown { name, .. } => {
+ (name.clone(), TranslationData::Literal(name.clone()))
}
+ _ => (
+ notif.body.notification_type().to_string(),
+ notif.body.notification_type().translation_data(),
+ ),
};
+ let text = notif.body.to_string();
+ let translatable_text = notif.body.translation_data();
Self {
id: notif.id.into(),
@@ -548,7 +530,9 @@ impl From for Notification {
created: notif.created,
name,
+ translatable_name,
text,
+ translatable_text,
link,
actions,
}
diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs
index 8b212ef162..07d526f552 100644
--- a/apps/labrinth/src/models/v3/projects.rs
+++ b/apps/labrinth/src/models/v3/projects.rs
@@ -7,8 +7,11 @@ use crate::database::models::version_item::VersionQueryResult;
use crate::models::ids::{
OrganizationId, ProjectId, TeamId, ThreadId, VersionId,
};
+use ariadne::i18n::I18nEnum;
+use ariadne::i18n_enum;
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
+use derive_more::Display;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use validator::Validate;
@@ -417,22 +420,26 @@ impl From for Link {
}
/// A status decides the visibility of a project in search, URLs, and the whole site itself.
-/// Approved - Project is displayed on search, and accessible by URL
-/// Rejected - Project is not displayed on search, and not accessible by URL (Temporary state, project can reapply)
-/// Draft - Project is not displayed on search, and not accessible by URL
-/// Unlisted - Project is not displayed on search, but accessible by URL
-/// Withheld - Same as unlisted, but set by a moderator. Cannot be switched to another type without moderator approval
-/// Processing - Project is not displayed on search, and not accessible by URL (Temporary state, project under review)
-/// Scheduled - Project is scheduled to be released in the future
-/// Private - Project is approved, but is not viewable to the public
-#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)]
+/// - Approved - Project is displayed on search, and accessible by URL
+/// - Rejected - Project is not displayed on search, and not accessible by URL (Temporary state, project can reapply)
+/// - Draft - Project is not displayed on search, and not accessible by URL
+/// - Unlisted - Project is not displayed on search, but accessible by URL
+/// - Withheld - Same as unlisted, but set by a moderator. Cannot be switched to another type without moderator approval
+/// - Processing - Project is not displayed on search, and not accessible by URL (Temporary state, project under review)
+/// - Scheduled - Project is scheduled to be released in the future
+/// - Private - Project is approved, but is not viewable to the public
+#[derive(
+ Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug, Display,
+)]
#[serde(rename_all = "lowercase")]
pub enum ProjectStatus {
+ #[display("Listed")]
Approved,
Archived,
Rejected,
Draft,
Unlisted,
+ #[display("Under review")]
Processing,
Withheld,
Scheduled,
@@ -440,11 +447,20 @@ pub enum ProjectStatus {
Unknown,
}
-impl std::fmt::Display for ProjectStatus {
- fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
- write!(fmt, "{}", self.as_str())
- }
-}
+i18n_enum!(
+ ProjectStatus,
+ root_key: "labrinth.project_status",
+ Approved! => "approved",
+ Archived! => "archived",
+ Rejected! => "rejected",
+ Draft! => "draft",
+ Unlisted! => "unlisted",
+ Processing! => "processing",
+ Withheld! => "withheld",
+ Scheduled! => "scheduled",
+ Private! => "private",
+ Unknown! => "unknown",
+);
impl ProjectStatus {
pub fn from_string(string: &str) -> ProjectStatus {
@@ -460,33 +476,9 @@ impl ProjectStatus {
_ => ProjectStatus::Unknown,
}
}
+
pub fn as_str(&self) -> &'static str {
- match self {
- ProjectStatus::Approved => "approved",
- ProjectStatus::Rejected => "rejected",
- ProjectStatus::Draft => "draft",
- ProjectStatus::Unlisted => "unlisted",
- ProjectStatus::Processing => "processing",
- ProjectStatus::Unknown => "unknown",
- ProjectStatus::Archived => "archived",
- ProjectStatus::Withheld => "withheld",
- ProjectStatus::Scheduled => "scheduled",
- ProjectStatus::Private => "private",
- }
- }
- pub fn as_friendly_str(&self) -> &'static str {
- match self {
- ProjectStatus::Approved => "Listed",
- ProjectStatus::Rejected => "Rejected",
- ProjectStatus::Draft => "Draft",
- ProjectStatus::Unlisted => "Unlisted",
- ProjectStatus::Processing => "Under review",
- ProjectStatus::Unknown => "Unknown",
- ProjectStatus::Archived => "Archived",
- ProjectStatus::Withheld => "Withheld",
- ProjectStatus::Scheduled => "Scheduled",
- ProjectStatus::Private => "Private",
- }
+ self.translation_id()
}
pub fn iterator() -> impl Iterator- {
diff --git a/apps/labrinth/src/queue/analytics.rs b/apps/labrinth/src/queue/analytics.rs
index 4269edaeb4..56f18b2cf6 100644
--- a/apps/labrinth/src/queue/analytics.rs
+++ b/apps/labrinth/src/queue/analytics.rs
@@ -1,7 +1,7 @@
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::models::analytics::{Download, PageView, Playtime};
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use dashmap::{DashMap, DashSet};
use redis::cmd;
use sqlx::PgPool;
diff --git a/apps/labrinth/src/queue/email.rs b/apps/labrinth/src/queue/email.rs
index e249af0c9b..6a86ffdad1 100644
--- a/apps/labrinth/src/queue/email.rs
+++ b/apps/labrinth/src/queue/email.rs
@@ -8,7 +8,8 @@ use crate::models::notifications::NotificationBody;
use crate::models::v3::notifications::{
NotificationChannel, NotificationDeliveryStatus,
};
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
+use ariadne::i18n_enum;
use chrono::Utc;
use futures::stream::{FuturesUnordered, StreamExt};
use lettre::message::Mailbox;
@@ -114,6 +115,17 @@ pub enum MailError {
HttpTemplate(#[from] reqwest::Error),
}
+i18n_enum!(
+ MailError,
+ root_key: "labrinth.error.mail",
+ Env(..) => "environment",
+ Mail(cause) => "email",
+ Address(cause) => "address",
+ Smtp(cause) => "smtp",
+ Uninitialized! => "uninitialized",
+ HttpTemplate(cause) => "http_template",
+);
+
#[derive(Clone)]
pub struct EmailQueue {
pg: PgPool,
diff --git a/apps/labrinth/src/queue/email/templates.rs b/apps/labrinth/src/queue/email/templates.rs
index 9c824da1b2..f0923d3e84 100644
--- a/apps/labrinth/src/queue/email/templates.rs
+++ b/apps/labrinth/src/queue/email/templates.rs
@@ -6,7 +6,7 @@ use crate::database::models::{
};
use crate::database::redis::RedisPool;
use crate::models::v3::notifications::NotificationBody;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use ariadne::ids::base62_impl::to_base62;
use futures::TryFutureExt;
use lettre::Message;
diff --git a/apps/labrinth/src/queue/moderation.rs b/apps/labrinth/src/queue/moderation.rs
index 9595328297..867c8eb3ec 100644
--- a/apps/labrinth/src/queue/moderation.rs
+++ b/apps/labrinth/src/queue/moderation.rs
@@ -3,12 +3,13 @@ use crate::database;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::thread_item::ThreadMessageBuilder;
use crate::database::redis::RedisPool;
+use crate::models::error::AsApiError;
use crate::models::ids::ProjectId;
use crate::models::notifications::NotificationBody;
use crate::models::pack::{PackFile, PackFileHash, PackFormat};
use crate::models::projects::ProjectStatus;
use crate::models::threads::MessageBody;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use dashmap::DashSet;
use hex::ToHex;
use itertools::Itertools;
@@ -674,9 +675,8 @@ impl AutomatedModerationQueue {
format!(
"*<{}/user/AutoMod|AutoMod>* changed project status from *{}* to *Rejected*",
dotenvy::var("SITE_URL")?,
- &project.inner.status.as_friendly_str(),
- )
- .to_string(),
+ &project.inner.status,
+ ),
),
)
.await
diff --git a/apps/labrinth/src/queue/payouts.rs b/apps/labrinth/src/queue/payouts.rs
index 86704e12f3..c721041e15 100644
--- a/apps/labrinth/src/queue/payouts.rs
+++ b/apps/labrinth/src/queue/payouts.rs
@@ -6,7 +6,7 @@ use crate::models::payouts::{
PayoutMethodType,
};
use crate::models::projects::MonetizationStatus;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::util::webhook::{
PayoutSourceAlertType, send_slack_payout_source_alert_webhook,
};
@@ -882,8 +882,8 @@ pub async fn process_payout(
MonetizationStatus::Monetized.as_str(),
&*crate::models::projects::ProjectStatus::iterator()
.filter(|x| !x.is_hidden())
- .map(|x| x.to_string())
- .collect::>(),
+ .map(|x| x.as_str().to_string())
+ .collect::>(),
)
.fetch(&mut *transaction)
.try_fold(DashMap::new(), |acc: DashMap>, r| {
@@ -905,7 +905,7 @@ pub async fn process_payout(
MonetizationStatus::Monetized.as_str(),
&*crate::models::projects::ProjectStatus::iterator()
.filter(|x| !x.is_hidden())
- .map(|x| x.to_string())
+ .map(|x| x.as_str().to_string())
.collect::>(),
)
.fetch(&mut *transaction)
diff --git a/apps/labrinth/src/routes/analytics.rs b/apps/labrinth/src/routes/analytics.rs
index 2bdd5ffa44..bca8993f0a 100644
--- a/apps/labrinth/src/routes/analytics.rs
+++ b/apps/labrinth/src/routes/analytics.rs
@@ -5,7 +5,7 @@ use crate::models::pats::Scopes;
use crate::queue::analytics::AnalyticsQueue;
use crate::queue::maxmind::MaxMindIndexer;
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::util::date::get_current_tenths_of_ms;
use crate::util::env::parse_strings_from_var;
use actix_web::{HttpRequest, HttpResponse};
diff --git a/apps/labrinth/src/routes/debug/mod.rs b/apps/labrinth/src/routes/debug/mod.rs
index b97e088392..eeb9822b65 100644
--- a/apps/labrinth/src/routes/debug/mod.rs
+++ b/apps/labrinth/src/routes/debug/mod.rs
@@ -1,4 +1,4 @@
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::util::cors::default_cors;
use crate::util::guards::admin_key_guard;
use actix_web::{HttpResponse, get};
diff --git a/apps/labrinth/src/routes/error.rs b/apps/labrinth/src/routes/error.rs
new file mode 100644
index 0000000000..86f0c0ee18
--- /dev/null
+++ b/apps/labrinth/src/routes/error.rs
@@ -0,0 +1,468 @@
+use crate::database::models::loader_fields::VersionFieldParseError;
+use crate::file_hosting::FileHostingError;
+use crate::models::error::AsApiError;
+use actix_web::http::StatusCode;
+use actix_web::{HttpResponse, ResponseError};
+use ariadne::i18n_enum;
+use thiserror::Error;
+
+#[derive(Error, Debug)]
+pub enum ApiError {
+ #[error("Environment Error")]
+ Env(#[from] dotenvy::Error),
+ #[error("Error while uploading file: {0}")]
+ FileHosting(#[from] FileHostingError),
+ #[error("Database Error: {0}")]
+ Database(#[from] crate::database::models::DatabaseError),
+ #[error("Database Error: {0}")]
+ SqlxDatabase(#[from] sqlx::Error),
+ #[error("Database Error: {0}")]
+ RedisDatabase(#[from] redis::RedisError),
+ #[error("Clickhouse Error: {0}")]
+ Clickhouse(#[from] clickhouse::error::Error),
+ #[error("Internal server error: {0}")]
+ Xml(serde_xml_rs::Error),
+ #[error("Deserialization error: {0}")]
+ Json(#[from] serde_json::Error),
+ #[error("Authentication Error: {0}")]
+ Authentication(#[from] crate::auth::AuthenticationError),
+ #[error("Authentication Error: {0}")]
+ SpecificAuthentication(#[from] SpecificAuthenticationError),
+ // TODO: Use an I18nEnum instead of a String
+ #[error("Invalid Input: {0}")]
+ InvalidInput(String),
+ #[error("Invalid Input: {0}")]
+ InvalidLoaderField(#[from] VersionFieldParseError),
+ // TODO: Perhaps remove this in favor of InvalidInput?
+ #[error("Error while validating input: {0}")]
+ Validation(String),
+ #[error("Search Error: {0}")]
+ Search(#[from] meilisearch_sdk::errors::Error),
+ #[error("Indexing Error: {0}")]
+ Indexing(#[from] crate::search::indexing::IndexingError),
+ // TODO: Use an I18nEnum instead of a String
+ #[error("Payments Error: {0}")]
+ Payments(String),
+ // TODO: Use an I18nEnum instead of a String
+ #[error("Discord Error: {0}")]
+ Discord(String),
+ #[error("Slack Webhook Error: Error while sending projects webhook")]
+ Slack,
+ #[error("Captcha Error. Try resubmitting the form.")]
+ Turnstile,
+ #[error("Error while decoding Base62: {0}")]
+ Decoding(#[from] ariadne::ids::DecodingError),
+ #[error("Image Parsing Error: {0}")]
+ ImageParse(#[from] image::ImageError),
+ #[error("Password Hashing Error: {0}")]
+ PasswordHashing(#[from] argon2::password_hash::Error),
+ #[error("{0}")]
+ Mail(#[from] crate::queue::email::MailError),
+ #[error("Error while rerouting request: {0}")]
+ Reroute(#[from] reqwest::Error),
+ #[error("Unable to read Zip Archive: {0}")]
+ Zip(#[from] zip::result::ZipError),
+ #[error("IO Error: {0}")]
+ Io(#[from] std::io::Error),
+ #[error("Resource not found")]
+ NotFound,
+ #[error("The requested route does not exist")]
+ RouteNotFound,
+ // TODO: Use an I18nEnum instead of a String
+ #[error("Conflict: {0}")]
+ Conflict(String),
+ #[error("External tax compliance API Error")]
+ TaxComplianceApi,
+ #[error(
+ "You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
+ )]
+ RateLimitError(u128, u32),
+ #[error("Error while interacting with payment processor: {0}")]
+ Stripe(#[from] stripe::StripeError),
+}
+
+i18n_enum!(
+ ApiError,
+ root_key: "labrinth.error",
+ Env(..) => "environment_error",
+ FileHosting(cause) => "file_hosting_error",
+ Database(cause) => "database_error",
+ SqlxDatabase(cause) => "database_error",
+ RedisDatabase(cause) => "database_error",
+ Clickhouse(cause) => "clickhouse_error",
+ Xml(cause) => "xml_error",
+ Json(cause) => "json_error",
+ Authentication(cause) => "unauthorized",
+ SpecificAuthentication(cause) => "unauthorized",
+ InvalidInput(cause) => "invalid_input",
+ InvalidLoaderField(cause) => "invalid_input",
+ Validation(cause) => "invalid_input.validation",
+ Search(cause) => "search_error",
+ Indexing(cause) => "indexing_error",
+ Payments(cause) => "payments_error",
+ Discord(cause) => "discord_error",
+ Slack! => "slack_error",
+ Turnstile! => "turnstile_error",
+ Decoding(cause) => "decoding_error",
+ ImageParse(cause) => "invalid_image",
+ PasswordHashing(cause) => "password_hashing_error",
+ Mail(transparent cause) => "mail_error",
+ Reroute(cause) => "reroute_error",
+ Zip(cause) => "zip_error",
+ Io(cause) => "io_error",
+ NotFound! => "not_found",
+ RouteNotFound! => "not_found.route",
+ Conflict(cause) => "conflict",
+ TaxComplianceApi! => "tax_compliance_api_error",
+ RateLimitError(wait_ms, total_allowed_requests) => "ratelimit_error",
+ Stripe(cause) => "stripe_error",
+);
+
+#[derive(Clone, Debug, Error)]
+pub enum SpecificAuthenticationError {
+ #[error("You do not have permission to read all affiliate codes!")]
+ ReadAllAffiliateCodes,
+ #[error("You do not have permission to create an affiliate code!")]
+ CreateAffiliateCode,
+ #[error("Affiliate user not found!")]
+ UnknownAffiliateUser,
+ #[error("You do not have permission to read an affiliate code!")]
+ ReadAffiliateCode,
+ #[error("You do not have permission to delete an affiliate code!")]
+ DeleteAffiliateCode,
+ #[error("You do not have permission to refund a subscription!")]
+ Refund,
+ #[error("Invalid master key")]
+ InvalidMasterKey,
+ #[error(
+ "You don't have sufficient permissions to interact with this OAuth application"
+ )]
+ InsufficientOAuthPermissions,
+ #[error("You don't have permission to set this status!")]
+ SetStatus,
+ #[error(
+ "The password change flow code is invalid or has expired. Did you copy it promptly and correctly?"
+ )]
+ InvalidFlowCode,
+ #[error("You must specify the old password to change your password!")]
+ OldPasswordNotSpecified,
+ #[error("You are not authorized to upload images for this project")]
+ UploadProjectImages,
+ #[error("You are not authorized to upload images for this version")]
+ UploadVersionImages,
+ #[error("You are not authorized to upload images for this thread message")]
+ UploadThreadMessageImages,
+ #[error("You are not authorized to upload images for this report")]
+ UploadReportImages,
+ #[error("You are not authorized to read this notification!")]
+ NotificationRead,
+ #[error("You are not authorized to delete this notification!")]
+ NotificationDelete,
+ #[error(
+ "You do not have permission to see the OAuth clients of this user!"
+ )]
+ SeeOAuthClients,
+ #[error(
+ "You do not have the permissions to edit the slug of this organization!"
+ )]
+ EditOrgSlug,
+ #[error(
+ "You do not have the permissions to edit the description of this organization!"
+ )]
+ EditOrgDescription,
+ #[error(
+ "You do not have the permissions to edit the name of this organization!"
+ )]
+ EditOrgName,
+ #[error("You do not have permission to edit this organization!")]
+ EditOrg,
+ #[error("You don't have permission to delete this organization!")]
+ DeleteOrg,
+ #[error(
+ "You need to be an owner of a project to add it to an organization!"
+ )]
+ NotProjectOwnerForAddToOrg,
+ #[error("You do not have permission to add projects to this organization!")]
+ AddToOrg,
+ #[error("You don't have permission to edit this organization's icon.")]
+ EditOrgIcon,
+ #[error("You do not have permission to edit this project!")]
+ EditProject,
+ #[error(
+ "You do not have the permissions to edit the name of this project!"
+ )]
+ EditProjectName,
+ #[error(
+ "You do not have the permissions to edit the summary of this project!"
+ )]
+ EditProjectSummary,
+ #[error(
+ "You do not have the permissions to edit the status of this project!"
+ )]
+ EditProjectStatus,
+ #[error("You don't have permission to set this status!")]
+ RestrictedProjectStatus,
+ #[error(
+ "You do not have the permissions to edit the requested status of this project!"
+ )]
+ EditProjectRequestedStatus,
+ #[error(
+ "You do not have the permissions to edit the license URL of this project!"
+ )]
+ EditProjectLicenseUrl,
+ #[error(
+ "You do not have the permissions to edit the slug of this project!"
+ )]
+ EditProjectSlug,
+ #[error(
+ "You do not have the permissions to edit the license of this project!"
+ )]
+ EditProjectLicense,
+ #[error(
+ "You do not have the permissions to edit the links of this project!"
+ )]
+ EditProjectLinks,
+ #[error(
+ "You do not have the permissions to edit the moderation message of this project!"
+ )]
+ EditProjectModerationMessage,
+ #[error(
+ "You do not have the permissions to edit the moderation message body of this project!"
+ )]
+ EditProjectModerationMessageBody,
+ #[error(
+ "You do not have the permissions to edit the description (body) of this project!"
+ )]
+ EditProjectDescription,
+ #[error(
+ "You do not have the permissions to edit the monetization status of this project!"
+ )]
+ EditProjectMonetizationStatus,
+ #[error(
+ "You do not have the permissions to edit the side types migration review status of this project!"
+ )]
+ EditProjectSideTypesMigrationReviewStatus,
+ #[error(
+ "You do not have the permissions to edit the additional categories of this project!"
+ )]
+ EditProjectAdditionalCategories,
+ #[error(
+ "You do not have the permissions to edit the categories of this project!"
+ )]
+ EditProjectCategories,
+ #[error("You do not have the permissions to bulk edit project {0}!")]
+ BulkEditProject(String),
+ #[error("You are not a member of project {0}!")]
+ NotMemberOfProject(String),
+ #[error("You don't have permission to edit this project's icon.")]
+ EditProjectIcon,
+ #[error("You have reached the maximum of gallery images to upload.")]
+ MaximumGalleryImages,
+ #[error("You don't have permission to edit this project's gallery.")]
+ EditProjectGallery,
+ #[error("You don't have permission to delete this project!")]
+ DeleteProject,
+ #[error("Unable to obtain user IP address!")]
+ UnknownUserIp,
+ #[error(
+ "You do not have permission to upload a version for this shared instance."
+ )]
+ SharedInstanceUploadVersion,
+ #[error("You do not have permission to edit this shared instance.")]
+ EditSharedInstance,
+ #[error("You do not have permission to delete this shared instance.")]
+ DeleteSharedInstance,
+ #[error(
+ "You do not have permission to delete this shared instance version."
+ )]
+ DeleteSharedInstanceVersion,
+ #[error("You don't have permission to invite users to this team")]
+ InviteUsersToTeam,
+ #[error("You don't have permission to invite users to this organization")]
+ InviteUsersToOrg,
+ #[error(
+ "You do not have permission to give this user default project permissions."
+ )]
+ GiveUserDefaultProjectPermissions,
+ #[error("You don't have permission to edit members of this team")]
+ EditTeamMembers,
+ #[error(
+ "You cannot override the project permissions of the organization owner!"
+ )]
+ OverrideOrgOwnerDefaultProjectPermissions,
+ #[error("You don't have permission to edit the ownership of this team")]
+ EditTeamOwnership,
+ #[error("The owner can't be removed from a team")]
+ RemoveOwnerFromTeam,
+ #[error("You do not have permission to remove a member from this team")]
+ RemoveTeamMember,
+ #[error("You do not have permission to cancel a team invite")]
+ CancelTeamInvite,
+ #[error(
+ "You do not have permission to remove a member from this organization"
+ )]
+ RemoveOrgMember,
+ #[error("You do not have permission to cancel an organization invite")]
+ CancelOrgInvite,
+ #[error("You cannot delete this message!")]
+ DeleteMessage,
+ #[error("You do not have permission to get a user from their email!")]
+ GetUserFromEmail,
+ #[error("You do not have the permissions to edit the role of this user!")]
+ EditUserRole,
+ #[error("You do not have the permissions to edit the badges of this user!")]
+ EditUserBadges,
+ #[error(
+ "You do not have the permissions to edit the venmo handle of this user!"
+ )]
+ EditUserVenmoHandle,
+ #[error("You do not have permission to edit this user!")]
+ EditUser,
+ #[error("You don't have permission to edit this user's icon.")]
+ EditUserIcon,
+ #[error("You do not have permission to delete this user!")]
+ DeleteUser,
+ #[error(
+ "You do not have permission to see the projects this user follows!"
+ )]
+ SeeUserFollows,
+ #[error(
+ "You do not have permission to see the notifications of this user!"
+ )]
+ SeeUserNotifications,
+ #[error("You don't have permission to delete this file!")]
+ DeleteFile,
+ #[error("You do not have the permissions to edit this version!")]
+ EditVersion,
+ #[error("You don't have permission to set the downloads of this mod")]
+ SetModDownloads,
+ #[error("You do not have permission to delete versions in this team")]
+ DeleteVersionsInTeam,
+}
+
+i18n_enum!(
+ SpecificAuthenticationError,
+ root_key: "labrinth.error.unauthorized.specific",
+ ReadAllAffiliateCodes! => "read_all_affiliate_codes",
+ CreateAffiliateCode! => "create_affiliate_code",
+ UnknownAffiliateUser! => "unknown_affiliate_user",
+ ReadAffiliateCode! => "read_affiliate_code",
+ DeleteAffiliateCode! => "delete_affiliate_code",
+ Refund! => "refund",
+ InvalidMasterKey! => "invalid_master_key",
+ InsufficientOAuthPermissions! => "insufficient_oauth_permissions",
+ SetStatus! => "set_status",
+ InvalidFlowCode! => "invalid_flow_code",
+ OldPasswordNotSpecified! => "old_password_not_specified",
+ UploadProjectImages! => "upload_project_images",
+ UploadVersionImages! => "upload_version_images",
+ UploadThreadMessageImages! => "upload_thread_message_images",
+ UploadReportImages! => "upload_report_images",
+ NotificationRead! => "notification_read",
+ NotificationDelete! => "notification_delete",
+ SeeOAuthClients! => "see_oauth_clients",
+ EditOrgSlug! => "edit_org_slug",
+ EditOrgDescription! => "edit_org_description",
+ EditOrgName! => "edit_org_name",
+ EditOrg! => "edit_org",
+ DeleteOrg! => "delete_org",
+ NotProjectOwnerForAddToOrg! => "not_project_owner_for_add_to_org",
+ AddToOrg! => "add_to_org",
+ EditOrgIcon! => "edit_org_icon",
+ EditProject! => "edit_project",
+ EditProjectName! => "edit_project_name",
+ EditProjectSummary! => "edit_project_summary",
+ EditProjectStatus! => "edit_project_status",
+ RestrictedProjectStatus! => "restricted_project_status",
+ EditProjectRequestedStatus! => "edit_project_requested_status",
+ EditProjectLicenseUrl! => "edit_project_license_url",
+ EditProjectSlug! => "edit_project_slug",
+ EditProjectLicense! => "edit_project_license",
+ EditProjectLinks! => "edit_project_links",
+ EditProjectModerationMessage! => "edit_project_moderation_message",
+ EditProjectModerationMessageBody! => "edit_project_moderation_message_body",
+ EditProjectDescription! => "edit_project_description",
+ EditProjectMonetizationStatus! => "edit_project_monetization_status",
+ EditProjectSideTypesMigrationReviewStatus! => "edit_project_side_types_migration_review_status",
+ EditProjectAdditionalCategories! => "edit_project_additional_categories",
+ EditProjectCategories! => "edit_project_categories",
+ BulkEditProject(project_name) => "bulk_edit_project",
+ NotMemberOfProject(project_name) => "not_member_of_project",
+ EditProjectIcon! => "edit_project_icon",
+ MaximumGalleryImages! => "maximum_gallery_images",
+ EditProjectGallery! => "edit_project_gallery",
+ DeleteProject! => "delete_project",
+ UnknownUserIp! => "unknown_user_ip",
+ SharedInstanceUploadVersion! => "shared_instance_upload_version",
+ EditSharedInstance! => "edit_shared_instance",
+ DeleteSharedInstance! => "delete_shared_instance",
+ DeleteSharedInstanceVersion! => "delete_shared_instance_version",
+ InviteUsersToTeam! => "invite_users_to_team",
+ InviteUsersToOrg! => "invite_users_to_org",
+ GiveUserDefaultProjectPermissions! => "give_user_default_project_permissions",
+ EditTeamMembers! => "edit_team_members",
+ OverrideOrgOwnerDefaultProjectPermissions! => "override_org_owner_default_project_permissions",
+ EditTeamOwnership! => "edit_team_ownership",
+ RemoveOwnerFromTeam! => "remove_owner_from_team",
+ RemoveTeamMember! => "remove_team_member",
+ CancelTeamInvite! => "cancel_team_invite",
+ RemoveOrgMember! => "remove_org_member",
+ CancelOrgInvite! => "cancel_org_invite",
+ DeleteMessage! => "delete_message",
+ GetUserFromEmail! => "get_user_from_email",
+ EditUserRole! => "edit_user_role",
+ EditUserBadges! => "edit_user_badges",
+ EditUserVenmoHandle! => "edit_user_venmo_handle",
+ EditUser! => "edit_user",
+ EditUserIcon! => "edit_user_icon",
+ DeleteUser! => "delete_user",
+ SeeUserFollows! => "see_user_follows",
+ SeeUserNotifications! => "see_user_notifications",
+ DeleteFile! => "delete_file",
+ EditVersion! => "edit_version",
+ SetModDownloads! => "set_mod_downloads",
+ DeleteVersionsInTeam! => "delete_versions_in_team",
+);
+
+impl ResponseError for ApiError {
+ fn status_code(&self) -> StatusCode {
+ match self {
+ ApiError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ ApiError::Database(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ ApiError::SqlxDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ ApiError::RedisDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ ApiError::Clickhouse(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ ApiError::Authentication(..) => StatusCode::UNAUTHORIZED,
+ ApiError::SpecificAuthentication(..) => StatusCode::UNAUTHORIZED,
+ ApiError::Xml(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ ApiError::Json(..) => StatusCode::BAD_REQUEST,
+ ApiError::Search(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ ApiError::Indexing(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ ApiError::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ ApiError::InvalidInput(..) => StatusCode::BAD_REQUEST,
+ ApiError::InvalidLoaderField(..) => StatusCode::BAD_REQUEST,
+ ApiError::Validation(..) => StatusCode::BAD_REQUEST,
+ ApiError::Payments(..) => StatusCode::FAILED_DEPENDENCY,
+ ApiError::Discord(..) => StatusCode::FAILED_DEPENDENCY,
+ ApiError::Turnstile => StatusCode::BAD_REQUEST,
+ ApiError::Decoding(..) => StatusCode::BAD_REQUEST,
+ ApiError::ImageParse(..) => StatusCode::BAD_REQUEST,
+ ApiError::PasswordHashing(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ ApiError::NotFound => StatusCode::NOT_FOUND,
+ ApiError::RouteNotFound => StatusCode::NOT_FOUND,
+ ApiError::Conflict(..) => StatusCode::CONFLICT,
+ ApiError::TaxComplianceApi => StatusCode::INTERNAL_SERVER_ERROR,
+ ApiError::Zip(..) => StatusCode::BAD_REQUEST,
+ ApiError::Io(..) => StatusCode::BAD_REQUEST,
+ ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
+ ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
+ ApiError::Slack => StatusCode::INTERNAL_SERVER_ERROR,
+ }
+ }
+
+ fn error_response(&self) -> HttpResponse {
+ HttpResponse::build(self.status_code()).json(self.as_api_error())
+ }
+}
diff --git a/apps/labrinth/src/routes/internal/admin.rs b/apps/labrinth/src/routes/internal/admin.rs
index 9d9de66797..53beffc344 100644
--- a/apps/labrinth/src/routes/internal/admin.rs
+++ b/apps/labrinth/src/routes/internal/admin.rs
@@ -9,7 +9,7 @@ use crate::queue::analytics::AnalyticsQueue;
use crate::queue::maxmind::MaxMindIndexer;
use crate::queue::moderation::AUTOMOD_ID;
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::search::SearchConfig;
use crate::util::date::get_current_tenths_of_ms;
use crate::util::guards::admin_key_guard;
diff --git a/apps/labrinth/src/routes/internal/affiliate.rs b/apps/labrinth/src/routes/internal/affiliate.rs
index be56d9b7a8..bbf8638cd9 100644
--- a/apps/labrinth/src/routes/internal/affiliate.rs
+++ b/apps/labrinth/src/routes/internal/affiliate.rs
@@ -20,7 +20,7 @@ use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
-use crate::routes::ApiError;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(
@@ -52,9 +52,8 @@ async fn code_get_all(
.await?;
if !user.role.is_admin() {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to read all affiliate codes!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::ReadAllAffiliateCodes,
));
}
@@ -80,7 +79,7 @@ async fn code_create(
pool: web::Data,
redis: web::Data,
session_queue: web::Data,
- body: web::Json,
+ body: Json,
) -> Result, ApiError> {
let (_, creator) = get_user_from_headers(
&req,
@@ -92,9 +91,8 @@ async fn code_create(
.await?;
if !creator.role.is_admin() {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to create an affiliate code!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::CreateAffiliateCode,
));
}
@@ -103,8 +101,8 @@ async fn code_create(
let Some(_affiliate_user) =
DBUser::get_id(affiliate_id, &**pool, &redis).await?
else {
- return Err(ApiError::CustomAuthentication(
- "Affiliate user not found!".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::UnknownAffiliateUser,
));
};
@@ -147,8 +145,8 @@ async fn code_get(
.await?;
if !user.role.is_admin() {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to read an affiliate code!".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::ReadAffiliateCode,
));
}
@@ -182,9 +180,8 @@ async fn code_delete(
.await?;
if !user.role.is_admin() {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to delete an affiliate code!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::DeleteAffiliateCode,
));
}
diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs
index c44058ba38..d395e24dd4 100644
--- a/apps/labrinth/src/routes/internal/billing.rs
+++ b/apps/labrinth/src/routes/internal/billing.rs
@@ -5,8 +5,7 @@ use crate::database::models::user_item::DBUser;
use crate::database::models::user_subscription_item::DBUserSubscription;
use crate::database::models::users_redeemals::{self, UserRedeemal};
use crate::database::models::{
- charge_item, generate_charge_id, generate_user_subscription_id,
- product_item, user_subscription_item,
+ generate_charge_id, generate_user_subscription_id, product_item,
};
use crate::database::redis::RedisPool;
use crate::models::billing::{
@@ -18,7 +17,7 @@ use crate::models::notifications::NotificationBody;
use crate::models::pats::Scopes;
use crate::models::users::Badges;
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
use crate::util::archon::{ArchonClient, CreateServerRequest, Specs};
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
use ariadne::ids::base62_impl::{parse_base62, to_base62};
@@ -115,26 +114,25 @@ pub async fn subscriptions(
.await?
.1;
- let subscriptions =
- user_subscription_item::DBUserSubscription::get_all_user(
- if let Some(user_id) = query.user_id {
- if user.role.is_admin() {
- user_id.into()
- } else {
- return Err(ApiError::InvalidInput(
- "You cannot see the subscriptions of other users!"
- .to_string(),
- ));
- }
+ let subscriptions = DBUserSubscription::get_all_user(
+ if let Some(user_id) = query.user_id {
+ if user.role.is_admin() {
+ user_id.into()
} else {
- user.id.into()
- },
- &**pool,
- )
- .await?
- .into_iter()
- .map(UserSubscription::from)
- .collect::>();
+ return Err(ApiError::InvalidInput(
+ "You cannot see the subscriptions of other users!"
+ .to_string(),
+ ));
+ }
+ } else {
+ user.id.into()
+ },
+ &**pool,
+ )
+ .await?
+ .into_iter()
+ .map(UserSubscription::from)
+ .collect::>();
Ok(HttpResponse::Ok().json(subscriptions))
}
@@ -177,8 +175,8 @@ pub async fn refund_charge(
let (id,) = info.into_inner();
if !user.role.is_admin() {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to refund a subscription!".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::Refund,
));
}
@@ -431,8 +429,8 @@ pub async fn edit_subscription(
///
/// Returns the proration requirement (see [`Proration`]) and the new product price's amount.
fn proration_amount(
- open_charge: &charge_item::DBCharge,
- subscription: &user_subscription_item::DBUserSubscription,
+ open_charge: &DBCharge,
+ subscription: &DBUserSubscription,
current_price: &product_item::DBProductPrice,
new_product_price: &product_item::DBProductPrice,
) -> Result<(Proration, i32), ApiError> {
@@ -499,7 +497,7 @@ pub async fn edit_subscription(
txn: &mut sqlx::PgTransaction<'_>,
stripe_client: &stripe::Client,
user: &crate::models::v3::users::User,
- subscription: &user_subscription_item::DBUserSubscription,
+ subscription: &DBUserSubscription,
current_product_price: &product_item::DBProductPrice,
new_product_price: product_item::DBProductPrice,
new_region: String,
@@ -600,9 +598,9 @@ pub async fn edit_subscription(
redis: &RedisPool,
txn: &mut sqlx::PgTransaction<'_>,
stripe_client: &stripe::Client,
- open_charge: &mut charge_item::DBCharge,
+ open_charge: &mut DBCharge,
user: &crate::models::v3::users::User,
- subscription: &user_subscription_item::DBUserSubscription,
+ subscription: &DBUserSubscription,
current_price: &product_item::DBProductPrice,
new_product_price: &product_item::DBProductPrice,
new_region: Option,
@@ -700,10 +698,9 @@ pub async fn edit_subscription(
let dry = query.dry.unwrap_or_default();
- let subscription =
- user_subscription_item::DBUserSubscription::get(id.into(), &**pool)
- .await?
- .ok_or_else(|| ApiError::NotFound)?;
+ let subscription = DBUserSubscription::get(id.into(), &**pool)
+ .await?
+ .ok_or_else(|| ApiError::NotFound)?;
if subscription.user_id != user.id.into() && !user.role.is_admin() {
return Err(ApiError::NotFound);
@@ -711,16 +708,15 @@ pub async fn edit_subscription(
let mut transaction = pool.begin().await?;
- let mut open_charge = charge_item::DBCharge::get_open_subscription(
- subscription.id,
- &mut *transaction,
- )
- .await?
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "Could not find open charge for this subscription".to_string(),
- )
- })?;
+ let mut open_charge =
+ DBCharge::get_open_subscription(subscription.id, &mut *transaction)
+ .await?
+ .ok_or_else(|| {
+ ApiError::InvalidInput(
+ "Could not find open charge for this subscription"
+ .to_string(),
+ )
+ })?;
let current_price = product_item::DBProductPrice::get(
subscription.price_id,
@@ -1015,23 +1011,22 @@ pub async fn charges(
.await?
.1;
- let charges =
- crate::database::models::charge_item::DBCharge::get_from_user(
- if let Some(user_id) = query.user_id {
- if user.role.is_admin() {
- user_id.into()
- } else {
- return Err(ApiError::InvalidInput(
- "You cannot see the subscriptions of other users!"
- .to_string(),
- ));
- }
+ let charges = DBCharge::get_from_user(
+ if let Some(user_id) = query.user_id {
+ if user.role.is_admin() {
+ user_id.into()
} else {
- user.id.into()
- },
- &**pool,
- )
- .await?;
+ return Err(ApiError::InvalidInput(
+ "You cannot see the subscriptions of other users!"
+ .to_string(),
+ ));
+ }
+ } else {
+ user.id.into()
+ },
+ &**pool,
+ )
+ .await?;
Ok(HttpResponse::Ok().json(
charges
@@ -1216,11 +1211,7 @@ pub async fn remove_payment_method(
.await?;
let user_subscriptions =
- user_subscription_item::DBUserSubscription::get_all_user(
- user.id.into(),
- &**pool,
- )
- .await?;
+ DBUserSubscription::get_all_user(user.id.into(), &**pool).await?;
if user_subscriptions
.iter()
@@ -1314,16 +1305,14 @@ pub async fn active_servers(
.get("X-Master-Key")
.is_none_or(|it| it.as_bytes() != master_key.as_bytes())
{
- return Err(ApiError::CustomAuthentication(
- "Invalid master key".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::InvalidMasterKey,
));
}
- let servers = user_subscription_item::DBUserSubscription::get_all_servers(
- query.subscription_status,
- &**pool,
- )
- .await?;
+ let servers =
+ DBUserSubscription::get_all_servers(query.subscription_status, &**pool)
+ .await?;
#[derive(Serialize)]
struct ActiveServer {
@@ -1389,7 +1378,7 @@ pub struct PaymentRequest {
#[serde(flatten)]
pub type_: PaymentRequestType,
pub charge: ChargeRequestType,
- pub existing_payment_intent: Option,
+ pub existing_payment_intent: Option,
pub metadata: Option,
}
@@ -1543,11 +1532,7 @@ pub async fn initiate_payment(
let (price, currency_code, interval, price_id, charge_id, charge_type) =
match payment_request.charge {
ChargeRequestType::Existing { id } => {
- let charge =
- crate::database::models::charge_item::DBCharge::get(
- id.into(),
- &**pool,
- )
+ let charge = DBCharge::get(id.into(), &**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(
@@ -1620,12 +1605,11 @@ pub async fn initiate_payment(
if let Price::Recurring { .. } = price_item.prices
&& product.unitary
{
- let user_subscriptions =
- user_subscription_item::DBUserSubscription::get_all_user(
- user.id.into(),
- &**pool,
- )
- .await?;
+ let user_subscriptions = DBUserSubscription::get_all_user(
+ user.id.into(),
+ &**pool,
+ )
+ .await?;
let user_products = product_item::DBProductPrice::get_many(
&user_subscriptions
@@ -1808,12 +1792,11 @@ pub async fn stripe_webhook(
&dotenvy::var("STRIPE_WEBHOOK_SECRET")?,
) {
struct PaymentIntentMetadata {
- pub user_item: crate::database::models::user_item::DBUser,
+ pub user_item: DBUser,
pub product_price_item: product_item::DBProductPrice,
pub product_item: product_item::DBProduct,
- pub charge_item: crate::database::models::charge_item::DBCharge,
- pub user_subscription_item:
- Option,
+ pub charge_item: DBCharge,
+ pub user_subscription_item: Option,
pub payment_metadata: Option,
pub new_region: Option,
}
@@ -1838,11 +1821,7 @@ pub async fn stripe_webhook(
break 'metadata;
};
- let Some(user) =
- crate::database::models::user_item::DBUser::get_id(
- user_id, pool, redis,
- )
- .await?
+ let Some(user) = DBUser::get_id(user_id, pool, redis).await?
else {
break 'metadata;
};
@@ -1873,10 +1852,7 @@ pub async fn stripe_webhook(
let (charge, price, product, subscription, new_region) =
if let Some(mut charge) =
- crate::database::models::charge_item::DBCharge::get(
- charge_id, pool,
- )
- .await?
+ DBCharge::get(charge_id, pool).await?
{
let Some(price) = product_item::DBProductPrice::get(
charge.price_id,
@@ -1903,11 +1879,9 @@ pub async fn stripe_webhook(
charge.upsert(transaction).await?;
if let Some(subscription_id) = charge.subscription_id {
- let maybe_subscription = user_subscription_item::DBUserSubscription::get(
- subscription_id,
- pool,
- )
- .await?;
+ let maybe_subscription =
+ DBUserSubscription::get(subscription_id, pool)
+ .await?;
let Some(mut subscription) = maybe_subscription
else {
@@ -1996,14 +1970,23 @@ pub async fn stripe_webhook(
break 'metadata;
};
- let subscription = if let Some(mut subscription) = user_subscription_item::DBUserSubscription::get(subscription_id, pool).await? {
- subscription.status = SubscriptionStatus::Unprovisioned;
+ let subscription = if let Some(
+ mut subscription,
+ ) =
+ DBUserSubscription::get(
+ subscription_id,
+ pool,
+ )
+ .await?
+ {
+ subscription.status =
+ SubscriptionStatus::Unprovisioned;
subscription.price_id = price_id;
subscription.interval = interval;
subscription
} else {
- user_subscription_item::DBUserSubscription {
+ DBUserSubscription {
id: subscription_id,
user_id,
price_id,
@@ -2416,7 +2399,7 @@ pub async fn stripe_webhook(
}
transaction.commit().await?;
- crate::database::models::user_item::DBUser::clear_caches(
+ DBUser::clear_caches(
&[(metadata.user_item.id, None)],
&redis,
)
@@ -2582,11 +2565,7 @@ async fn get_or_create_customer(
.execute(pool)
.await?;
- crate::database::models::user_item::DBUser::clear_caches(
- &[(user_id.into(), None)],
- redis,
- )
- .await?;
+ DBUser::clear_caches(&[(user_id.into(), None)], redis).await?;
Ok(customer.id)
}
@@ -2606,17 +2585,16 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) {
// It should be unprovisioned.
let all_charges = DBCharge::get_unprovision(&pool).await?;
- let mut all_subscriptions =
- user_subscription_item::DBUserSubscription::get_many(
- &all_charges
- .iter()
- .filter_map(|x| x.subscription_id)
- .collect::>()
- .into_iter()
- .collect::>(),
- &pool,
- )
- .await?;
+ let mut all_subscriptions = DBUserSubscription::get_many(
+ &all_charges
+ .iter()
+ .filter_map(|x| x.subscription_id)
+ .collect::>()
+ .into_iter()
+ .collect::>(),
+ &pool,
+ )
+ .await?;
let subscription_prices = product_item::DBProductPrice::get_many(
&all_subscriptions
.iter()
@@ -2637,7 +2615,7 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) {
&pool,
)
.await?;
- let users = crate::database::models::DBUser::get_many_ids(
+ let users = DBUser::get_many_ids(
&all_subscriptions
.iter()
.map(|x| x.user_id)
@@ -2744,7 +2722,7 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) {
clear_cache_users.push(user.id);
}
- crate::database::models::DBUser::clear_caches(
+ DBUser::clear_caches(
&clear_cache_users
.into_iter()
.map(|x| (x, None))
@@ -2965,7 +2943,7 @@ pub async fn index_billing(
)
.await?;
- let users = crate::database::models::DBUser::get_many_ids(
+ let users = DBUser::get_many_ids(
&charges_to_do
.iter()
.map(|x| x.user_id)
diff --git a/apps/labrinth/src/routes/internal/external_notifications.rs b/apps/labrinth/src/routes/internal/external_notifications.rs
index 0efa627679..488857ffcb 100644
--- a/apps/labrinth/src/routes/internal/external_notifications.rs
+++ b/apps/labrinth/src/routes/internal/external_notifications.rs
@@ -3,7 +3,7 @@ use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::user_item::DBUser;
use crate::database::redis::RedisPool;
use crate::models::v3::notifications::NotificationBody;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::util::guards::external_notification_key_guard;
use actix_web::web;
use actix_web::{HttpResponse, post};
diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs
index 246f053491..b437227fc4 100644
--- a/apps/labrinth/src/routes/internal/flows.rs
+++ b/apps/labrinth/src/routes/internal/flows.rs
@@ -1,3 +1,4 @@
+use crate::auth::templates::ErrorPage;
use crate::auth::validate::{
get_full_user_from_headers, get_user_record_from_bearer_token,
};
@@ -12,7 +13,7 @@ use crate::models::pats::Scopes;
use crate::models::users::{Badges, Role};
use crate::queue::email::EmailQueue;
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
use crate::routes::internal::session::issue_session;
use crate::util::captcha::check_hcaptcha;
use crate::util::env::parse_strings_from_var;
@@ -83,9 +84,7 @@ impl TempUser {
redis: &RedisPool,
) -> Result {
if let Some(email) = &self.email
- && crate::database::models::DBUser::get_by_email(email, client)
- .await?
- .is_some()
+ && DBUser::get_by_email(email, client).await?.is_some()
{
return Err(AuthenticationError::DuplicateUser);
}
@@ -107,12 +106,7 @@ impl TempUser {
}
);
- let new_id = crate::database::models::DBUser::get(
- &test_username,
- client,
- redis,
- )
- .await?;
+ let new_id = DBUser::get(&test_username, client, redis).await?;
if new_id.is_none() {
username = Some(test_username);
@@ -163,7 +157,7 @@ impl TempUser {
};
if let Some(username) = username {
- crate::database::models::DBUser {
+ DBUser {
id: user_id,
github_id: if provider == AuthProvider::GitHub {
Some(
@@ -1112,29 +1106,29 @@ pub async fn auth_callback(
client: Data,
file_host: Data>,
redis: Data,
-) -> Result {
- let state_string = query
- .get("state")
- .ok_or_else(|| AuthenticationError::InvalidCredentials)?
- .clone();
-
- let state = state_string.clone();
- let res: Result = async move {
+) -> Result {
+ let res = async || {
+ let state = query
+ .get("state")
+ .ok_or_else(|| AuthenticationError::InvalidCredentials)?
+ .clone();
+
let flow = DBFlow::get(&state, &redis).await?;
// Extract cookie header from request
if let Some(DBFlow::OAuth {
- user_id,
- provider,
- url,
- }) = flow
+ user_id,
+ provider,
+ url,
+ }) = flow
{
DBFlow::remove(&state, &redis).await?;
let token = provider.get_token(query).await?;
let oauth_user = provider.get_user(&token).await?;
- let user_id_opt = provider.get_user_id(&oauth_user.id, &**client).await?;
+ let user_id_opt =
+ provider.get_user_id(&oauth_user.id, &**client).await?;
let mut transaction = client.begin().await?;
if let Some(id) = user_id {
@@ -1146,9 +1140,12 @@ pub async fn auth_callback(
.update_user_id(id, Some(&oauth_user.id), &mut transaction)
.await?;
- let user = crate::database::models::DBUser::get_id(id, &**client, &redis).await?;
+ let user = crate::database::models::DBUser::get_id(
+ id, &**client, &redis,
+ )
+ .await?;
- if provider == AuthProvider::PayPal {
+ if provider == AuthProvider::PayPal {
sqlx::query!(
"
UPDATE users
@@ -1163,22 +1160,32 @@ pub async fn auth_callback(
.execute(&mut *transaction)
.await?;
} else if let Some(user) = user {
- NotificationBuilder { body: NotificationBody::AuthProviderAdded { provider: provider.as_str().to_string() } }
- .insert(user.id, &mut transaction, &redis)
- .await?;
+ NotificationBuilder {
+ body: NotificationBody::AuthProviderAdded {
+ provider: provider.as_str().to_string(),
+ },
+ }
+ .insert(user.id, &mut transaction, &redis)
+ .await?;
}
transaction.commit().await?;
- crate::database::models::DBUser::clear_caches(&[(id, None)], &redis).await?;
+ crate::database::models::DBUser::clear_caches(
+ &[(id, None)],
+ &redis,
+ )
+ .await?;
Ok(HttpResponse::TemporaryRedirect()
.append_header(("Location", &*url))
.json(serde_json::json!({ "url": url })))
} else {
let user_id = if let Some(user_id) = user_id_opt {
- let user = crate::database::models::DBUser::get_id(user_id, &**client, &redis)
- .await?
- .ok_or_else(|| AuthenticationError::InvalidCredentials)?;
+ let user = crate::database::models::DBUser::get_id(
+ user_id, &**client, &redis,
+ )
+ .await?
+ .ok_or_else(|| AuthenticationError::InvalidCredentials)?;
if user.totp_secret.is_some() {
let flow = DBFlow::Login2FA { user_id: user.id }
@@ -1199,10 +1206,20 @@ pub async fn auth_callback(
user_id
} else {
- oauth_user.create_account(provider, &mut transaction, &client, &file_host, &redis).await?
+ oauth_user
+ .create_account(
+ provider,
+ &mut transaction,
+ &client,
+ &file_host,
+ &redis,
+ )
+ .await?
};
- let session = issue_session(req, user_id, &mut transaction, &redis).await?;
+ let session =
+ issue_session(&req, user_id, &mut transaction, &redis)
+ .await?;
transaction.commit().await?;
let redirect_url = format!(
@@ -1222,11 +1239,13 @@ pub async fn auth_callback(
.json(serde_json::json!({ "url": redirect_url })))
}
} else {
- Err::(AuthenticationError::InvalidCredentials)
+ Err::(
+ AuthenticationError::InvalidCredentials,
+ )
}
- }.await;
+ };
- Ok(res?)
+ Ok(res().await?)
}
#[derive(Deserialize)]
@@ -1429,7 +1448,8 @@ pub async fn create_account_with_password(
.insert(&mut transaction)
.await?;
- let session = issue_session(req, user_id, &mut transaction, &redis).await?;
+ let session =
+ issue_session(&req, user_id, &mut transaction, &redis).await?;
let res = crate::models::sessions::Session::from(session, true, None);
let mailbox: Mailbox = new_account.email.parse().map_err(|_| {
@@ -1523,7 +1543,7 @@ pub async fn login_password(
} else {
let mut transaction = pool.begin().await?;
let session =
- issue_session(req, user.id, &mut transaction, &redis).await?;
+ issue_session(&req, user.id, &mut transaction, &redis).await?;
let res = crate::models::sessions::Session::from(session, true, None);
transaction.commit().await?;
@@ -1583,9 +1603,7 @@ async fn validate_2fa_code(
Ok(true)
} else if allow_backup {
- let backup_codes =
- crate::database::models::DBUser::get_backup_codes(user_id, pool)
- .await?;
+ let backup_codes = DBUser::get_backup_codes(user_id, pool).await?;
if !backup_codes.contains(&input) {
Ok(false)
@@ -1603,11 +1621,7 @@ async fn validate_2fa_code(
.execute(&mut **transaction)
.await?;
- crate::database::models::DBUser::clear_caches(
- &[(user_id, None)],
- redis,
- )
- .await?;
+ DBUser::clear_caches(&[(user_id, None)], redis).await?;
Ok(true)
}
@@ -1653,7 +1667,7 @@ pub async fn login_2fa(
DBFlow::remove(&login.flow, &redis).await?;
let session =
- issue_session(req, user_id, &mut transaction, &redis).await?;
+ issue_session(&req, user_id, &mut transaction, &redis).await?;
let res = crate::models::sessions::Session::from(session, true, None);
transaction.commit().await?;
@@ -2029,8 +2043,8 @@ pub async fn change_password(
Some(user)
} else {
- return Err(ApiError::CustomAuthentication(
- "The password change flow code is invalid or has expired. Did you copy it promptly and correctly?".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::InvalidFlowCode,
));
}
} else {
@@ -2057,11 +2071,12 @@ pub async fn change_password(
}
if let Some(pass) = user.password.as_ref() {
- let old_password = change_password.old_password.as_ref().ok_or_else(|| {
- ApiError::CustomAuthentication(
- "You must specify the old password to change your password!".to_string(),
- )
- })?;
+ let old_password =
+ change_password.old_password.as_ref().ok_or_else(|| {
+ ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::OldPasswordNotSpecified,
+ )
+ })?;
let hasher = Argon2::default();
hasher.verify_password(
diff --git a/apps/labrinth/src/routes/internal/gdpr.rs b/apps/labrinth/src/routes/internal/gdpr.rs
index 790225191b..f4bc8213d3 100644
--- a/apps/labrinth/src/routes/internal/gdpr.rs
+++ b/apps/labrinth/src/routes/internal/gdpr.rs
@@ -2,7 +2,7 @@ use crate::auth::get_user_from_headers;
use crate::database::redis::RedisPool;
use crate::models::pats::Scopes;
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use actix_web::{HttpRequest, HttpResponse, post, web};
use sqlx::PgPool;
diff --git a/apps/labrinth/src/routes/internal/medal.rs b/apps/labrinth/src/routes/internal/medal.rs
index 4bc29e3994..5201779b90 100644
--- a/apps/labrinth/src/routes/internal/medal.rs
+++ b/apps/labrinth/src/routes/internal/medal.rs
@@ -9,7 +9,7 @@ use crate::database::models::users_redeemals::{
Offer, RedeemalLookupFields, Status, UserRedeemal,
};
use crate::database::redis::RedisPool;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::routes::internal::billing::try_process_user_redeemal;
use crate::util::guards::medal_key_guard;
diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs
index 2701c2a68b..33025cc301 100644
--- a/apps/labrinth/src/routes/internal/mod.rs
+++ b/apps/labrinth/src/routes/internal/mod.rs
@@ -11,8 +11,8 @@ pub mod session;
pub mod statuses;
-pub use super::ApiError;
use super::v3::oauth_clients;
+pub use crate::routes::error::ApiError;
use crate::util::cors::default_cors;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
diff --git a/apps/labrinth/src/routes/internal/moderation.rs b/apps/labrinth/src/routes/internal/moderation.rs
index 049ce727a1..acfcb27dc0 100644
--- a/apps/labrinth/src/routes/internal/moderation.rs
+++ b/apps/labrinth/src/routes/internal/moderation.rs
@@ -1,9 +1,9 @@
-use super::ApiError;
use crate::database;
use crate::database::redis::RedisPool;
use crate::models::projects::ProjectStatus;
use crate::queue::moderation::{ApprovalType, IdentifiedFile, MissingMetadata};
use crate::queue::session::AuthQueue;
+use crate::routes::error::ApiError;
use crate::{auth::check_is_moderator_from_headers, models::pats::Scopes};
use actix_web::{HttpRequest, HttpResponse, web};
use ariadne::ids::random_base62;
diff --git a/apps/labrinth/src/routes/internal/pats.rs b/apps/labrinth/src/routes/internal/pats.rs
index 7070c72b92..cf6693e6df 100644
--- a/apps/labrinth/src/routes/internal/pats.rs
+++ b/apps/labrinth/src/routes/internal/pats.rs
@@ -2,7 +2,7 @@ use crate::database;
use crate::database::models::generate_pat_id;
use crate::auth::get_user_from_headers;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::database::redis::RedisPool;
use actix_web::web::{self, Data};
diff --git a/apps/labrinth/src/routes/internal/session.rs b/apps/labrinth/src/routes/internal/session.rs
index 1b20e3aee9..375b91b7b3 100644
--- a/apps/labrinth/src/routes/internal/session.rs
+++ b/apps/labrinth/src/routes/internal/session.rs
@@ -6,7 +6,7 @@ use crate::database::redis::RedisPool;
use crate::models::pats::Scopes;
use crate::models::sessions::Session;
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::util::env::parse_var;
use actix_web::http::header::AUTHORIZATION;
use actix_web::web::{Data, ServiceConfig, scope};
@@ -84,12 +84,12 @@ pub async fn get_session_metadata(
}
pub async fn issue_session(
- req: HttpRequest,
+ req: &HttpRequest,
user_id: DBUserId,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
redis: &RedisPool,
) -> Result {
- let metadata = get_session_metadata(&req).await?;
+ let metadata = get_session_metadata(req).await?;
let session = ChaCha20Rng::from_entropy()
.sample_iter(&Alphanumeric)
@@ -244,7 +244,7 @@ pub async fn refresh(
DBSession::remove(session.id, &mut transaction).await?;
let new_session =
- issue_session(req, session.user_id, &mut transaction, &redis)
+ issue_session(&req, session.user_id, &mut transaction, &redis)
.await?;
transaction.commit().await?;
DBSession::clear_cache(
diff --git a/apps/labrinth/src/routes/internal/statuses.rs b/apps/labrinth/src/routes/internal/statuses.rs
index 416ea80534..edce1abc1f 100644
--- a/apps/labrinth/src/routes/internal/statuses.rs
+++ b/apps/labrinth/src/routes/internal/statuses.rs
@@ -8,7 +8,7 @@ use crate::queue::session::AuthQueue;
use crate::queue::socket::{
ActiveSocket, ActiveSockets, SocketId, TunnelSocketType,
};
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::sync::friends::{FRIENDS_CHANNEL_NAME, RedisFriendsMessage};
use crate::sync::status::{
get_user_status, push_back_user_expiry, replace_user_status,
diff --git a/apps/labrinth/src/routes/maven.rs b/apps/labrinth/src/routes/maven.rs
index 878f6dabcc..3926cba07f 100644
--- a/apps/labrinth/src/routes/maven.rs
+++ b/apps/labrinth/src/routes/maven.rs
@@ -9,12 +9,12 @@ use crate::database::redis::RedisPool;
use crate::models::ids::{ProjectId, VersionId};
use crate::models::pats::Scopes;
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::{auth::get_user_from_headers, database};
use actix_web::{HttpRequest, HttpResponse, get, route, web};
+use serde::Serialize;
use sqlx::PgPool;
use std::collections::HashSet;
-use yaserde::YaSerialize;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(maven_metadata);
@@ -23,45 +23,37 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(version_file);
}
-#[derive(Default, Debug, Clone, YaSerialize)]
-#[yaserde(rename = "metadata")]
+#[derive(Default, Debug, Clone, Serialize)]
+#[serde(rename = "metadata", rename_all = "camelCase")]
pub struct Metadata {
- #[yaserde(rename = "groupId")]
group_id: String,
- #[yaserde(rename = "artifactId")]
artifact_id: String,
versioning: Versioning,
}
-#[derive(Default, Debug, Clone, YaSerialize)]
-#[yaserde(rename = "versioning")]
+#[derive(Default, Debug, Clone, Serialize)]
+#[serde(rename = "versioning", rename_all = "camelCase")]
pub struct Versioning {
latest: String,
release: String,
versions: Versions,
- #[yaserde(rename = "lastUpdated")]
last_updated: String,
}
-#[derive(Default, Debug, Clone, YaSerialize)]
-#[yaserde(rename = "versions")]
+#[derive(Default, Debug, Clone, Serialize)]
+#[serde(rename = "versions")]
pub struct Versions {
- #[yaserde(rename = "version")]
+ #[serde(rename = "version")]
versions: Vec,
}
-#[derive(Default, Debug, Clone, YaSerialize)]
-#[yaserde(rename = "project", namespaces = { "" = "http://maven.apache.org/POM/4.0.0" })]
+#[derive(Default, Debug, Clone, Serialize)]
+#[serde(rename = "project", rename_all = "camelCase")]
pub struct MavenPom {
- #[yaserde(rename = "xsi:schemaLocation", attribute = true)]
+ #[serde(rename = "@xsi:schemaLocation")]
schema_location: String,
- #[yaserde(rename = "xmlns:xsi", attribute = true)]
- xsi: String,
- #[yaserde(rename = "modelVersion")]
model_version: String,
- #[yaserde(rename = "groupId")]
group_id: String,
- #[yaserde(rename = "artifactId")]
artifact_id: String,
version: String,
name: String,
@@ -157,7 +149,7 @@ pub async fn maven_metadata(
Ok(HttpResponse::Ok()
.content_type("text/xml")
- .body(yaserde::ser::to_string(&respdata).map_err(ApiError::Xml)?))
+ .body(serde_xml_rs::to_string(&respdata).map_err(ApiError::Xml)?))
}
async fn find_version(
@@ -215,7 +207,7 @@ async fn find_version(
if !game_versions.is_empty() {
let version_game_versions =
x.version_fields.clone().into_iter().find_map(|v| {
- MinecraftGameVersion::try_from_version_field(&v).ok()
+ MinecraftGameVersion::try_from_version_field(v).ok()
});
if let Some(version_game_versions) = version_game_versions {
bool &= version_game_versions
@@ -318,7 +310,6 @@ pub async fn version_file(
schema_location:
"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
.to_string(),
- xsi: "http://www.w3.org/2001/XMLSchema-instance".to_string(),
model_version: "4.0.0".to_string(),
group_id: "maven.modrinth".to_string(),
artifact_id: project_id,
@@ -326,9 +317,13 @@ pub async fn version_file(
name: project.inner.name,
description: project.inner.description,
};
- return Ok(HttpResponse::Ok()
- .content_type("text/xml")
- .body(yaserde::ser::to_string(&respdata).map_err(ApiError::Xml)?));
+ return Ok(HttpResponse::Ok().content_type("text/xml").body(
+ serde_xml_rs::SerdeXml::new()
+ .default_namespace("http://maven.apache.org/POM/4.0.0")
+ .namespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
+ .to_string(&respdata)
+ .map_err(ApiError::Xml)?,
+ ));
} else if let Some(selected_file) =
find_file(&project_id, &vnum, &version, &file)
{
diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs
index 6508792c2f..243ab7a8ba 100644
--- a/apps/labrinth/src/routes/mod.rs
+++ b/apps/labrinth/src/routes/mod.rs
@@ -1,13 +1,12 @@
-use crate::file_hosting::FileHostingError;
use crate::routes::analytics::{page_view_ingest, playtime_ingest};
use crate::util::cors::default_cors;
use crate::util::env::parse_strings_from_var;
use actix_cors::Cors;
use actix_files::Files;
-use actix_web::http::StatusCode;
use actix_web::{HttpResponse, web};
use futures::FutureExt;
+pub mod error;
pub mod internal;
pub mod v2;
pub mod v3;
@@ -84,150 +83,3 @@ pub fn root_config(cfg: &mut web::ServiceConfig) {
.service(Files::new("/", "assets/")),
);
}
-
-#[derive(thiserror::Error, Debug)]
-pub enum ApiError {
- #[error("Environment Error")]
- Env(#[from] dotenvy::Error),
- #[error("Error while uploading file: {0}")]
- FileHosting(#[from] FileHostingError),
- #[error("Database Error: {0}")]
- Database(#[from] crate::database::models::DatabaseError),
- #[error("Database Error: {0}")]
- SqlxDatabase(#[from] sqlx::Error),
- #[error("Database Error: {0}")]
- RedisDatabase(#[from] redis::RedisError),
- #[error("Clickhouse Error: {0}")]
- Clickhouse(#[from] clickhouse::error::Error),
- #[error("Internal server error: {0}")]
- Xml(String),
- #[error("Deserialization error: {0}")]
- Json(#[from] serde_json::Error),
- #[error("Authentication Error: {0}")]
- Authentication(#[from] crate::auth::AuthenticationError),
- #[error("Authentication Error: {0}")]
- CustomAuthentication(String),
- #[error("Invalid Input: {0}")]
- InvalidInput(String),
- #[error("Error while validating input: {0}")]
- Validation(String),
- #[error("Search Error: {0}")]
- Search(#[from] meilisearch_sdk::errors::Error),
- #[error("Indexing Error: {0}")]
- Indexing(#[from] crate::search::indexing::IndexingError),
- #[error("Payments Error: {0}")]
- Payments(String),
- #[error("Discord Error: {0}")]
- Discord(String),
- #[error("Slack Webhook Error: {0}")]
- Slack(String),
- #[error("Captcha Error. Try resubmitting the form.")]
- Turnstile,
- #[error("Error while decoding Base62: {0}")]
- Decoding(#[from] ariadne::ids::DecodingError),
- #[error("Image Parsing Error: {0}")]
- ImageParse(#[from] image::ImageError),
- #[error("Password Hashing Error: {0}")]
- PasswordHashing(#[from] argon2::password_hash::Error),
- #[error("{0}")]
- Mail(#[from] crate::queue::email::MailError),
- #[error("Error while rerouting request: {0}")]
- Reroute(#[from] reqwest::Error),
- #[error("Unable to read Zip Archive: {0}")]
- Zip(#[from] zip::result::ZipError),
- #[error("IO Error: {0}")]
- Io(#[from] std::io::Error),
- #[error("Resource not found")]
- NotFound,
- #[error("Conflict: {0}")]
- Conflict(String),
- #[error("External tax compliance API Error")]
- TaxComplianceApi,
- #[error(
- "You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining."
- )]
- RateLimitError(u128, u32),
- #[error("Error while interacting with payment processor: {0}")]
- Stripe(#[from] stripe::StripeError),
-}
-
-impl ApiError {
- pub fn as_api_error<'a>(&self) -> crate::models::error::ApiError<'a> {
- crate::models::error::ApiError {
- error: match self {
- ApiError::Env(..) => "environment_error",
- ApiError::Database(..) => "database_error",
- ApiError::SqlxDatabase(..) => "database_error",
- ApiError::RedisDatabase(..) => "database_error",
- ApiError::Authentication(..) => "unauthorized",
- ApiError::CustomAuthentication(..) => "unauthorized",
- ApiError::Xml(..) => "xml_error",
- ApiError::Json(..) => "json_error",
- ApiError::Search(..) => "search_error",
- ApiError::Indexing(..) => "indexing_error",
- ApiError::FileHosting(..) => "file_hosting_error",
- ApiError::InvalidInput(..) => "invalid_input",
- ApiError::Validation(..) => "invalid_input",
- ApiError::Payments(..) => "payments_error",
- ApiError::Discord(..) => "discord_error",
- ApiError::Turnstile => "turnstile_error",
- ApiError::Decoding(..) => "decoding_error",
- ApiError::ImageParse(..) => "invalid_image",
- ApiError::PasswordHashing(..) => "password_hashing_error",
- ApiError::Mail(..) => "mail_error",
- ApiError::Clickhouse(..) => "clickhouse_error",
- ApiError::Reroute(..) => "reroute_error",
- ApiError::NotFound => "not_found",
- ApiError::Conflict(..) => "conflict",
- ApiError::TaxComplianceApi => "tax_compliance_api_error",
- ApiError::Zip(..) => "zip_error",
- ApiError::Io(..) => "io_error",
- ApiError::RateLimitError(..) => "ratelimit_error",
- ApiError::Stripe(..) => "stripe_error",
- ApiError::Slack(..) => "slack_error",
- },
- description: self.to_string(),
- }
- }
-}
-
-impl actix_web::ResponseError for ApiError {
- fn status_code(&self) -> StatusCode {
- match self {
- ApiError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
- ApiError::Database(..) => StatusCode::INTERNAL_SERVER_ERROR,
- ApiError::SqlxDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
- ApiError::RedisDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
- ApiError::Clickhouse(..) => StatusCode::INTERNAL_SERVER_ERROR,
- ApiError::Authentication(..) => StatusCode::UNAUTHORIZED,
- ApiError::CustomAuthentication(..) => StatusCode::UNAUTHORIZED,
- ApiError::Xml(..) => StatusCode::INTERNAL_SERVER_ERROR,
- ApiError::Json(..) => StatusCode::BAD_REQUEST,
- ApiError::Search(..) => StatusCode::INTERNAL_SERVER_ERROR,
- ApiError::Indexing(..) => StatusCode::INTERNAL_SERVER_ERROR,
- ApiError::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR,
- ApiError::InvalidInput(..) => StatusCode::BAD_REQUEST,
- ApiError::Validation(..) => StatusCode::BAD_REQUEST,
- ApiError::Payments(..) => StatusCode::FAILED_DEPENDENCY,
- ApiError::Discord(..) => StatusCode::FAILED_DEPENDENCY,
- ApiError::Turnstile => StatusCode::BAD_REQUEST,
- ApiError::Decoding(..) => StatusCode::BAD_REQUEST,
- ApiError::ImageParse(..) => StatusCode::BAD_REQUEST,
- ApiError::PasswordHashing(..) => StatusCode::INTERNAL_SERVER_ERROR,
- ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
- ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
- ApiError::NotFound => StatusCode::NOT_FOUND,
- ApiError::Conflict(..) => StatusCode::CONFLICT,
- ApiError::TaxComplianceApi => StatusCode::INTERNAL_SERVER_ERROR,
- ApiError::Zip(..) => StatusCode::BAD_REQUEST,
- ApiError::Io(..) => StatusCode::BAD_REQUEST,
- ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
- ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
- ApiError::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR,
- }
- }
-
- fn error_response(&self) -> HttpResponse {
- HttpResponse::build(self.status_code()).json(self.as_api_error())
- }
-}
diff --git a/apps/labrinth/src/routes/not_found.rs b/apps/labrinth/src/routes/not_found.rs
index 2da930bd76..00e55155ba 100644
--- a/apps/labrinth/src/routes/not_found.rs
+++ b/apps/labrinth/src/routes/not_found.rs
@@ -1,11 +1,6 @@
-use crate::models::error::ApiError;
-use actix_web::{HttpResponse, Responder};
+use crate::routes::error::ApiError;
+use actix_web::{HttpResponse, ResponseError};
-pub async fn not_found() -> impl Responder {
- let data = ApiError {
- error: "not_found",
- description: "the requested route does not exist".to_string(),
- };
-
- HttpResponse::NotFound().json(data)
+pub async fn not_found() -> HttpResponse {
+ ApiError::RouteNotFound.error_response()
}
diff --git a/apps/labrinth/src/routes/updates.rs b/apps/labrinth/src/routes/updates.rs
index 08fe83fbe0..c30990dd8b 100644
--- a/apps/labrinth/src/routes/updates.rs
+++ b/apps/labrinth/src/routes/updates.rs
@@ -13,7 +13,7 @@ use crate::models::pats::Scopes;
use crate::models::projects::VersionType;
use crate::queue::session::AuthQueue;
-use super::ApiError;
+use crate::routes::error::ApiError;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(forge_updates);
diff --git a/apps/labrinth/src/routes/v2/mod.rs b/apps/labrinth/src/routes/v2/mod.rs
index 13f823a6a1..86c48030e2 100644
--- a/apps/labrinth/src/routes/v2/mod.rs
+++ b/apps/labrinth/src/routes/v2/mod.rs
@@ -12,7 +12,7 @@ mod version_creation;
pub mod version_file;
mod versions;
-pub use super::ApiError;
+pub use crate::routes::error::ApiError;
use crate::util::cors::default_cors;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
diff --git a/apps/labrinth/src/routes/v2/moderation.rs b/apps/labrinth/src/routes/v2/moderation.rs
index ff721e6cb9..bb9ac1dec2 100644
--- a/apps/labrinth/src/routes/v2/moderation.rs
+++ b/apps/labrinth/src/routes/v2/moderation.rs
@@ -1,7 +1,7 @@
-use super::ApiError;
use crate::models::projects::Project;
use crate::models::v2::projects::LegacyProject;
use crate::queue::session::AuthQueue;
+use crate::routes::error::ApiError;
use crate::routes::internal;
use crate::{database::redis::RedisPool, routes::v2_reroute};
use actix_web::{HttpRequest, HttpResponse, get, web};
diff --git a/apps/labrinth/src/routes/v2/notifications.rs b/apps/labrinth/src/routes/v2/notifications.rs
index 4cce65b49f..b368d203a5 100644
--- a/apps/labrinth/src/routes/v2/notifications.rs
+++ b/apps/labrinth/src/routes/v2/notifications.rs
@@ -3,7 +3,7 @@ use crate::models::ids::NotificationId;
use crate::models::notifications::Notification;
use crate::models::v2::notifications::LegacyNotification;
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::routes::v2_reroute;
use crate::routes::v3;
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs
index 9a45624309..0609a47752 100644
--- a/apps/labrinth/src/routes/v2/project_creation.rs
+++ b/apps/labrinth/src/routes/v2/project_creation.rs
@@ -8,8 +8,8 @@ use crate::models::v2::projects::{
DonationLink, LegacyProject, LegacySideType,
};
use crate::queue::session::AuthQueue;
+use crate::routes::v3::project_creation::NewGalleryItem;
use crate::routes::v3::project_creation::default_project_type;
-use crate::routes::v3::project_creation::{CreateError, NewGalleryItem};
use crate::routes::{v2_reroute, v3};
use actix_multipart::Multipart;
use actix_web::web::Data;
@@ -18,12 +18,12 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::postgres::PgPool;
+use super::version_creation::InitialVersionData;
+use crate::routes::v3::create_error::CreateError;
use std::collections::HashMap;
use std::sync::Arc;
use validator::Validate;
-use super::version_creation::InitialVersionData;
-
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(project_create);
}
diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs
index 234c458635..98ff822798 100644
--- a/apps/labrinth/src/routes/v2/projects.rs
+++ b/apps/labrinth/src/routes/v2/projects.rs
@@ -11,8 +11,9 @@ use crate::models::v2::projects::{
use crate::models::v2::search::LegacySearchResults;
use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
+use crate::routes::error::ApiError;
use crate::routes::v3::projects::ProjectIds;
-use crate::routes::{ApiError, v2_reroute, v3};
+use crate::routes::{v2_reroute, v3};
use crate::search::{SearchConfig, SearchError, search_for_project};
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
use serde::{Deserialize, Serialize};
diff --git a/apps/labrinth/src/routes/v2/reports.rs b/apps/labrinth/src/routes/v2/reports.rs
index 8804c47a2d..43f9be6272 100644
--- a/apps/labrinth/src/routes/v2/reports.rs
+++ b/apps/labrinth/src/routes/v2/reports.rs
@@ -2,7 +2,8 @@ use crate::database::redis::RedisPool;
use crate::models::reports::Report;
use crate::models::v2::reports::LegacyReport;
use crate::queue::session::AuthQueue;
-use crate::routes::{ApiError, v2_reroute, v3};
+use crate::routes::error::ApiError;
+use crate::routes::{v2_reroute, v3};
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
use serde::Deserialize;
use sqlx::PgPool;
diff --git a/apps/labrinth/src/routes/v2/statistics.rs b/apps/labrinth/src/routes/v2/statistics.rs
index 78bb9d6265..4fe84e1461 100644
--- a/apps/labrinth/src/routes/v2/statistics.rs
+++ b/apps/labrinth/src/routes/v2/statistics.rs
@@ -1,5 +1,6 @@
+use crate::routes::error::ApiError;
use crate::routes::{
- ApiError, v2_reroute,
+ v2_reroute,
v3::{self, statistics::V3Stats},
};
use actix_web::{HttpResponse, get, web};
diff --git a/apps/labrinth/src/routes/v2/tags.rs b/apps/labrinth/src/routes/v2/tags.rs
index 1a9ae9a353..b1560474e2 100644
--- a/apps/labrinth/src/routes/v2/tags.rs
+++ b/apps/labrinth/src/routes/v2/tags.rs
@@ -1,9 +1,9 @@
use std::collections::HashMap;
-use super::ApiError;
use crate::database::models::loader_fields::LoaderFieldEnumValue;
use crate::database::redis::RedisPool;
use crate::models::v2::projects::LegacySideType;
+use crate::routes::error::ApiError;
use crate::routes::v2_reroute::capitalize_first;
use crate::routes::v3::tags::{LinkPlatformQueryData, LoaderFieldsEnumQuery};
use crate::routes::{v2_reroute, v3};
diff --git a/apps/labrinth/src/routes/v2/teams.rs b/apps/labrinth/src/routes/v2/teams.rs
index 36d924ab2c..aebe7539db 100644
--- a/apps/labrinth/src/routes/v2/teams.rs
+++ b/apps/labrinth/src/routes/v2/teams.rs
@@ -5,7 +5,8 @@ use crate::models::teams::{
};
use crate::models::v2::teams::LegacyTeamMember;
use crate::queue::session::AuthQueue;
-use crate::routes::{ApiError, v2_reroute, v3};
+use crate::routes::error::ApiError;
+use crate::routes::{v2_reroute, v3};
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web};
use ariadne::ids::UserId;
use rust_decimal::Decimal;
diff --git a/apps/labrinth/src/routes/v2/threads.rs b/apps/labrinth/src/routes/v2/threads.rs
index 164de21adb..bf4f7b7150 100644
--- a/apps/labrinth/src/routes/v2/threads.rs
+++ b/apps/labrinth/src/routes/v2/threads.rs
@@ -6,7 +6,8 @@ use crate::models::ids::{ThreadId, ThreadMessageId};
use crate::models::threads::{MessageBody, Thread};
use crate::models::v2::threads::LegacyThread;
use crate::queue::session::AuthQueue;
-use crate::routes::{ApiError, v2_reroute, v3};
+use crate::routes::error::ApiError;
+use crate::routes::{v2_reroute, v3};
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
use serde::Deserialize;
use sqlx::PgPool;
diff --git a/apps/labrinth/src/routes/v2/users.rs b/apps/labrinth/src/routes/v2/users.rs
index 952717eed8..be26601d7b 100644
--- a/apps/labrinth/src/routes/v2/users.rs
+++ b/apps/labrinth/src/routes/v2/users.rs
@@ -7,7 +7,8 @@ use crate::models::v2::notifications::LegacyNotification;
use crate::models::v2::projects::LegacyProject;
use crate::models::v2::user::LegacyUser;
use crate::queue::session::AuthQueue;
-use crate::routes::{ApiError, v2_reroute, v3};
+use crate::routes::error::ApiError;
+use crate::routes::{v2_reroute, v3};
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
diff --git a/apps/labrinth/src/routes/v2/version_creation.rs b/apps/labrinth/src/routes/v2/version_creation.rs
index 9406ce5569..5bdd1cf554 100644
--- a/apps/labrinth/src/routes/v2/version_creation.rs
+++ b/apps/labrinth/src/routes/v2/version_creation.rs
@@ -9,7 +9,7 @@ use crate::models::projects::{
use crate::models::v2::projects::LegacyVersion;
use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
-use crate::routes::v3::project_creation::CreateError;
+use crate::routes::v3::create_error::CreateError;
use crate::routes::v3::version_creation;
use crate::routes::{v2_reroute, v3};
use actix_multipart::Multipart;
diff --git a/apps/labrinth/src/routes/v2/version_file.rs b/apps/labrinth/src/routes/v2/version_file.rs
index 1d843b5b06..ffd0da7abe 100644
--- a/apps/labrinth/src/routes/v2/version_file.rs
+++ b/apps/labrinth/src/routes/v2/version_file.rs
@@ -1,9 +1,9 @@
-use super::ApiError;
use crate::database::ReadOnlyPgPool;
use crate::database::redis::RedisPool;
use crate::models::projects::{Project, Version, VersionType};
use crate::models::v2::projects::{LegacyProject, LegacyVersion};
use crate::queue::session::AuthQueue;
+use crate::routes::error::ApiError;
use crate::routes::v3::version_file::HashQuery;
use crate::routes::{v2_reroute, v3};
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs
index 6e91f8a38a..ef8ca7206e 100644
--- a/apps/labrinth/src/routes/v2/versions.rs
+++ b/apps/labrinth/src/routes/v2/versions.rs
@@ -1,6 +1,5 @@
use std::collections::HashMap;
-use super::ApiError;
use crate::database::redis::RedisPool;
use crate::models;
use crate::models::ids::VersionId;
@@ -9,6 +8,7 @@ use crate::models::projects::{
};
use crate::models::v2::projects::LegacyVersion;
use crate::queue::session::AuthQueue;
+use crate::routes::error::ApiError;
use crate::routes::{v2_reroute, v3};
use crate::search::SearchConfig;
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
diff --git a/apps/labrinth/src/routes/v2_reroute.rs b/apps/labrinth/src/routes/v2_reroute.rs
index 0a99b0796c..f6a45f579f 100644
--- a/apps/labrinth/src/routes/v2_reroute.rs
+++ b/apps/labrinth/src/routes/v2_reroute.rs
@@ -1,8 +1,8 @@
use std::collections::HashMap;
-use super::ApiError;
-use super::v3::project_creation::CreateError;
use crate::models::v2::projects::LegacySideType;
+use crate::routes::error::ApiError;
+use crate::routes::v3::create_error::{CreateError, CreationInvalidInput};
use crate::util::actix::{
MultipartSegment, MultipartSegmentData, generate_multipart,
};
@@ -124,12 +124,11 @@ where
// Finishes the json segment, with aggregated content dispositions
{
let json_value = json.ok_or(CreateError::InvalidInput(
- "No json segment found in multipart.".to_string(),
+ CreationInvalidInput::NoJsonInMultipart,
))?;
- let mut json_segment =
- json_segment.ok_or(CreateError::InvalidInput(
- "No json segment found in multipart.".to_string(),
- ))?;
+ let mut json_segment = json_segment.ok_or(
+ CreateError::InvalidInput(CreationInvalidInput::NoJsonInMultipart),
+ )?;
// Call closure, with the json value and names of the other segments
let json_value: U = closure(json_value, content_dispositions).await?;
@@ -142,20 +141,13 @@ where
let (boundary, payload) = generate_multipart(segments);
- match (
+ if let Ok((key, value)) = (
"Content-Type",
format!("multipart/form-data; boundary={boundary}").as_str(),
)
.try_into_pair()
{
- Ok((key, value)) => {
- headers.insert(key, value);
- }
- Err(err) => {
- CreateError::InvalidInput(format!(
- "Error inserting test header: {err:?}."
- ));
- }
+ headers.insert(key, value);
};
let new_multipart =
diff --git a/apps/labrinth/src/routes/v3/analytics_get.rs b/apps/labrinth/src/routes/v3/analytics_get.rs
index b2c615726c..b531bc7338 100644
--- a/apps/labrinth/src/routes/v3/analytics_get.rs
+++ b/apps/labrinth/src/routes/v3/analytics_get.rs
@@ -1,7 +1,7 @@
-use super::ApiError;
use crate::database;
use crate::database::redis::RedisPool;
use crate::models::teams::ProjectPermissions;
+use crate::routes::error::ApiError;
use crate::{
auth::get_user_from_headers,
database::models::user_item,
diff --git a/apps/labrinth/src/routes/v3/collections.rs b/apps/labrinth/src/routes/v3/collections.rs
index 15488ba056..384a82aa90 100644
--- a/apps/labrinth/src/routes/v3/collections.rs
+++ b/apps/labrinth/src/routes/v3/collections.rs
@@ -9,8 +9,8 @@ use crate::models::collections::{Collection, CollectionStatus};
use crate::models::ids::{CollectionId, ProjectId};
use crate::models::pats::Scopes;
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
-use crate::routes::v3::project_creation::CreateError;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
+use crate::routes::v3::create_error::{CreateError, CreationInvalidInput};
use crate::util::img::delete_old_images;
use crate::util::routes::read_limited_from_payload;
use crate::util::validate::validation_errors_to_string;
@@ -77,7 +77,9 @@ pub async fn collection_create(
.1;
collection_create_data.validate().map_err(|err| {
- CreateError::InvalidInput(validation_errors_to_string(err, None))
+ CreateError::InvalidInput(CreationInvalidInput::Validation(
+ validation_errors_to_string(err, None),
+ ))
})?;
let mut transaction = client.begin().await?;
@@ -112,7 +114,7 @@ pub async fn collection_create(
let now = Utc::now();
collection_builder_actual.insert(&mut transaction).await?;
- let response = crate::models::collections::Collection {
+ let response = Collection {
id: collection_id,
user: collection_builder.user_id.into(),
name: collection_builder.name.clone(),
@@ -136,9 +138,9 @@ pub struct CollectionIds {
pub async fn collections_get(
req: HttpRequest,
web::Query(ids): web::Query,
- pool: web::Data,
- redis: web::Data,
- session_queue: web::Data,
+ pool: Data,
+ redis: Data,
+ session_queue: Data,
) -> Result {
let ids = serde_json::from_str::>(&ids.ids)?;
let ids = ids
@@ -172,9 +174,9 @@ pub async fn collections_get(
pub async fn collection_get(
req: HttpRequest,
info: web::Path<(String,)>,
- pool: web::Data,
- redis: web::Data,
- session_queue: web::Data,
+ pool: Data,
+ redis: Data,
+ session_queue: Data,
) -> Result {
let string = info.into_inner().0;
@@ -222,10 +224,10 @@ pub struct EditCollection {
pub async fn collection_edit(
req: HttpRequest,
info: web::Path<(String,)>,
- pool: web::Data,
+ pool: Data,
new_collection: web::Json,
- redis: web::Data,
- session_queue: web::Data,
+ redis: Data,
+ session_queue: Data,
) -> Result {
let user = get_user_from_headers(
&req,
@@ -288,8 +290,8 @@ pub async fn collection_edit(
|| collection_item.status.is_approved()
&& status.can_be_requested())
{
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to set this status!".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::SetStatus,
));
}
@@ -380,11 +382,11 @@ pub async fn collection_icon_edit(
web::Query(ext): web::Query,
req: HttpRequest,
info: web::Path<(String,)>,
- pool: web::Data,
- redis: web::Data,
- file_host: web::Data>,
+ pool: Data,
+ redis: Data,
+ file_host: Data>,
mut payload: web::Payload,
- session_queue: web::Data,
+ session_queue: Data,
) -> Result {
let user = get_user_from_headers(
&req,
@@ -464,10 +466,10 @@ pub async fn collection_icon_edit(
pub async fn delete_collection_icon(
req: HttpRequest,
info: web::Path<(String,)>,
- pool: web::Data,
- redis: web::Data,
- file_host: web::Data>,
- session_queue: web::Data,
+ pool: Data,
+ redis: Data,
+ file_host: Data>,
+ session_queue: Data,
) -> Result {
let user = get_user_from_headers(
&req,
@@ -523,9 +525,9 @@ pub async fn delete_collection_icon(
pub async fn collection_delete(
req: HttpRequest,
info: web::Path<(String,)>,
- pool: web::Data,
- redis: web::Data,
- session_queue: web::Data,
+ pool: Data,
+ redis: Data,
+ session_queue: Data,
) -> Result {
let user = get_user_from_headers(
&req,
diff --git a/apps/labrinth/src/routes/v3/create_error.rs b/apps/labrinth/src/routes/v3/create_error.rs
new file mode 100644
index 0000000000..9254133af5
--- /dev/null
+++ b/apps/labrinth/src/routes/v3/create_error.rs
@@ -0,0 +1,266 @@
+use crate::auth::AuthenticationError;
+use crate::database::models;
+use crate::database::models::loader_fields::VersionFieldParseError;
+use crate::file_hosting::FileHostingError;
+use crate::models::error::AsApiError;
+use crate::models::ids::ImageId;
+use crate::models::projects::VersionStatus;
+use crate::routes::error::ApiError;
+use crate::search::indexing::IndexingError;
+use crate::validate::ValidationWarning;
+use actix_web::HttpResponse;
+use actix_web::http::StatusCode;
+use ariadne::i18n_enum;
+use derive_more::{Display, From};
+use image::ImageError;
+use thiserror::Error;
+
+#[derive(Error, Debug)]
+pub enum CreateError {
+ #[error("Environment Error")]
+ EnvError(#[from] dotenvy::Error),
+ #[error("An unknown database error occurred")]
+ SqlxDatabaseError(#[from] sqlx::Error),
+ #[error("Database Error: {0}")]
+ DatabaseError(#[from] models::DatabaseError),
+ #[error("Indexing Error: {0}")]
+ IndexingError(#[from] IndexingError),
+ #[error("Error while parsing multipart payload: {0}")]
+ MultipartError(#[from] actix_multipart::MultipartError),
+ #[error("Error while parsing JSON: {0}")]
+ SerDeError(#[from] serde_json::Error),
+ #[error("Error while validating input: {0}")]
+ ValidationError(String),
+ #[error("Error while uploading file: {0}")]
+ FileHostingError(#[from] FileHostingError),
+ #[error("Error while validating uploaded file: {0}")]
+ FileValidationError(#[from] crate::validate::ValidationError),
+ #[error("{0}")]
+ MissingValueError(MissingValuePart),
+ #[error("Invalid format for image: {0}")]
+ InvalidIconFormat(ApiError),
+ #[error("Error with multipart data: {0}")]
+ InvalidInput(CreationInvalidInput),
+ #[error("Error with multipart data: {0}")]
+ InvalidLoaderField(#[from] VersionFieldParseError),
+ #[error("Invalid loader: {0}")]
+ InvalidLoader(String),
+ #[error("Invalid category: {0}")]
+ InvalidCategory(String),
+ #[error("Invalid file type for version file: {0}")]
+ InvalidFileType(String),
+ #[error("Slug is already taken!")]
+ SlugCollision,
+ #[error("Authentication Error: {0}")]
+ Unauthorized(#[from] AuthenticationError),
+ #[error("Authentication Error: {0}")]
+ CreationAuthenticationError(CreationAuthenticationError),
+ #[error("Image Parsing Error: {0}")]
+ ImageError(#[from] ImageError),
+}
+
+i18n_enum!(
+ CreateError,
+ root_key: "labrinth.error.creation",
+ EnvError(..) => "environment_error",
+ SqlxDatabaseError(..) => "database_error.unknown",
+ DatabaseError(cause) => "database_error",
+ IndexingError(cause) => "indexing_error",
+ MultipartError(cause) => "invalid_input.multipart",
+ SerDeError(cause) => "invalid_input.parsing",
+ ValidationError(cause) => "invalid_input.validation",
+ FileHostingError(cause) => "file_hosting_error",
+ FileValidationError(cause) => "invalid_input.file",
+ MissingValueError(transparent cause) => "invalid_input.missing_value",
+ InvalidIconFormat(cause) => "invalid_input.icon",
+ InvalidInput(cause) => "invalid_input",
+ InvalidLoaderField(cause) => "invalid_input",
+ InvalidLoader(loader) => "invalid_input.loader",
+ InvalidCategory(category) => "invalid_input.category",
+ InvalidFileType(extension) => "invalid_input.file_type",
+ SlugCollision! => "invalid_input.slug_collision",
+ Unauthorized(cause_or_reason) => "unauthorized",
+ CreationAuthenticationError(cause_or_reason) => "unauthorized",
+ ImageError(cause) => "invalid_image",
+);
+
+#[derive(Copy, Clone, Debug, Display)]
+pub enum MissingValuePart {
+ #[display("No `data` field in multipart upload")]
+ DataField,
+ #[display("Missing content name")]
+ ContentName,
+ #[display("Missing content file name")]
+ ContentFileName,
+ #[display("Missing content file extension")]
+ ContentFileExtension,
+ #[display("Missing project id")]
+ ProjectId,
+}
+
+i18n_enum!(
+ MissingValuePart,
+ root_key: "labrinth.error.creation.missing_value",
+ DataField! => "data_field",
+ ContentName! => "content_name",
+ ContentFileName! => "content_file_name",
+ ContentFileExtension! => "content_file_extension",
+ ProjectId! => "project_id",
+);
+
+#[derive(Debug, Display, From)]
+pub enum CreationInvalidInput {
+ #[display("Failed to get created team.")]
+ FailedGettingNewTeam,
+ #[display("`data` field must come before file fields")]
+ DataFieldOutOfOrder,
+ #[display("Duplicate multipart field name")]
+ DuplicateMultipartField,
+ #[display("Projects can only have one icon")]
+ MultipleIcons,
+ #[display("Only one gallery image can be featured.")]
+ MultipleFeaturedGallery,
+ #[display("Gallery image exceeds the maximum of {_0}.")]
+ GalleryImageTooLarge(&'static str),
+ #[display("File `{_0}` (field {_1}) isn't specified in the versions data")]
+ FileNotSpecified(String, String),
+ #[display("Some files were specified in initial_versions but not uploaded")]
+ InitialVersionsFilesMissing,
+ #[display("Invalid organization ID specified!")]
+ InvalidOrganizationId,
+ #[display("Project submitted for review with no initial versions")]
+ NoInitialVersions,
+ #[from]
+ #[display("Invalid SPDX license identifier: {_0}")]
+ InvalidLicenseId(spdx::ParseError),
+ #[display("Link platform {_0} does not exist.")]
+ NonexistentLinkPlatform(String),
+ #[display("Image {_0} is not unused or in the '{_1}' context")]
+ ImproperContextImage(ImageId, &'static str),
+ #[display("Image {_0} does not exist")]
+ NonexistentImage(ImageId),
+ #[display("Found project id in initial version for new project")]
+ ProjectIdInInitialVersion,
+ #[display("Icons must be smaller than {_0}")]
+ IconTooLarge(&'static str),
+ #[display("Status '{_0}' cannot be requested")]
+ CannotRequestStatus(VersionStatus),
+ #[display("An invalid project id was supplied")]
+ InvalidProjectId,
+ #[display("An invalid version id was supplied")]
+ InvalidVersionId,
+ #[display("`data` field is required")]
+ MissingDataField,
+ #[display("Versions must have at least one file uploaded to them")]
+ MissingAnyFiles,
+ #[display("At least one file must be specified")]
+ NoFilesSpecified,
+ #[display("Duplicate files are not allowed to be uploaded to Modrinth!")]
+ DuplicateFiles,
+ #[display("File names must not contain slashes!")]
+ FileNameHasSlashes,
+ #[display(
+ "Project file exceeds the maximum of {_0}. Contact a moderator or admin to request permission to upload larger files."
+ )]
+ ProjectFileTooLarge(&'static str),
+ #[display("Loader field '{_0}' does not exist for any loaders supplied")]
+ NonexistentLoaderField(String),
+ #[display("Missing mandatory loader fields: {_0}")]
+ MissingLoaderFields(String),
+ #[display("No json segment found in multipart.")]
+ NoJsonInMultipart,
+ FileValidation(ValidationWarning),
+ Validation(String),
+}
+
+i18n_enum!(
+ CreationInvalidInput,
+ root_key: "labrinth.error.creation.invalid_input",
+ FailedGettingNewTeam! => "failed_getting_new_team",
+ DataFieldOutOfOrder! => "data_field_out_of_order",
+ DuplicateMultipartField! => "duplicate_multipart_field",
+ MultipleIcons! => "multiple_icons",
+ MultipleFeaturedGallery! => "multiple_featured_gallery",
+ GalleryImageTooLarge(limit) => "gallery_image_too_large",
+ FileNotSpecified(file_name, in_field) => "file_not_specified",
+ InitialVersionsFilesMissing! => "initial_versions_files_missing",
+ InvalidOrganizationId! => "invalid_organization_id",
+ NoInitialVersions! => "no_initial_versions",
+ InvalidLicenseId(cause) => "invalid_license_id",
+ NonexistentLinkPlatform(platform) => "nonexistent_link_platform",
+ ImproperContextImage(image_id, proper_context) => "improper_context_image",
+ NonexistentImage(image_id) => "nonexistent_image",
+ ProjectIdInInitialVersion! => "project_id_in_initial_version",
+ IconTooLarge(limit) => "icon_too_large",
+ CannotRequestStatus(status) => "cannot_request_status",
+ InvalidProjectId! => "invalid_project_id",
+ InvalidVersionId! => "invalid_version_id",
+ MissingDataField! => "missing_data_field",
+ MissingAnyFiles! => "missing_any_files",
+ NoFilesSpecified! => "no_files_specified",
+ DuplicateFiles! => "duplicate_files",
+ FileNameHasSlashes! => "file_name_has_slashes",
+ ProjectFileTooLarge(limit) => "project_file_too_large",
+ NonexistentLoaderField(field) => "nonexistent_loader_field",
+ MissingLoaderFields(fields) => "missing_loader_fields",
+ NoJsonInMultipart! => "no_json_in_multipart",
+ FileValidation(transparent cause) => "file_validation",
+ Validation(transparent reason) => "validation",
+);
+
+#[derive(Copy, Clone, Debug, Display)]
+pub enum CreationAuthenticationError {
+ #[display(
+ "You do not have the permissions to create projects in this organization!"
+ )]
+ CreateProjectsInOrganization,
+ #[display("You don't have permission to upload this version!")]
+ UploadVersion,
+ #[display("You don't have permission to upload files to this version!")]
+ UploadFilesToVersion,
+}
+
+i18n_enum!(
+ CreationAuthenticationError,
+ root_key: "labrinth.error.creation.unauthorized",
+ CreateProjectsInOrganization! => "create_projects_in_organization",
+ UploadVersion! => "upload_version",
+ UploadFilesToVersion! => "upload_files_to_version",
+);
+
+impl actix_web::ResponseError for CreateError {
+ fn status_code(&self) -> StatusCode {
+ match self {
+ CreateError::EnvError(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ CreateError::SqlxDatabaseError(..) => {
+ StatusCode::INTERNAL_SERVER_ERROR
+ }
+ CreateError::DatabaseError(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ CreateError::IndexingError(..) => StatusCode::INTERNAL_SERVER_ERROR,
+ CreateError::FileHostingError(..) => {
+ StatusCode::INTERNAL_SERVER_ERROR
+ }
+ CreateError::SerDeError(..) => StatusCode::BAD_REQUEST,
+ CreateError::MultipartError(..) => StatusCode::BAD_REQUEST,
+ CreateError::MissingValueError(..) => StatusCode::BAD_REQUEST,
+ CreateError::InvalidIconFormat(..) => StatusCode::BAD_REQUEST,
+ CreateError::InvalidInput(..) => StatusCode::BAD_REQUEST,
+ CreateError::InvalidLoaderField(..) => StatusCode::BAD_REQUEST,
+ CreateError::InvalidLoader(..) => StatusCode::BAD_REQUEST,
+ CreateError::InvalidCategory(..) => StatusCode::BAD_REQUEST,
+ CreateError::InvalidFileType(..) => StatusCode::BAD_REQUEST,
+ CreateError::Unauthorized(..) => StatusCode::UNAUTHORIZED,
+ CreateError::CreationAuthenticationError(..) => {
+ StatusCode::UNAUTHORIZED
+ }
+ CreateError::SlugCollision => StatusCode::BAD_REQUEST,
+ CreateError::ValidationError(..) => StatusCode::BAD_REQUEST,
+ CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST,
+ CreateError::ImageError(..) => StatusCode::BAD_REQUEST,
+ }
+ }
+
+ fn error_response(&self) -> HttpResponse {
+ HttpResponse::build(self.status_code()).json(self.as_api_error())
+ }
+}
diff --git a/apps/labrinth/src/routes/v3/friends.rs b/apps/labrinth/src/routes/v3/friends.rs
index 5e99db83d3..a688a35d26 100644
--- a/apps/labrinth/src/routes/v3/friends.rs
+++ b/apps/labrinth/src/routes/v3/friends.rs
@@ -6,7 +6,7 @@ use crate::models::pats::Scopes;
use crate::models::users::UserFriend;
use crate::queue::session::AuthQueue;
use crate::queue::socket::ActiveSockets;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::routes::internal::statuses::{
broadcast_friends_message, send_message_to_user,
};
diff --git a/apps/labrinth/src/routes/v3/images.rs b/apps/labrinth/src/routes/v3/images.rs
index 93669a1aa8..efa097c9cf 100644
--- a/apps/labrinth/src/routes/v3/images.rs
+++ b/apps/labrinth/src/routes/v3/images.rs
@@ -12,7 +12,7 @@ use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::ids::{ReportId, ThreadMessageId, VersionId};
use crate::models::images::{Image, ImageContext};
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
use crate::util::img::upload_image_optimized;
use crate::util::routes::read_limited_from_payload;
use actix_web::{HttpRequest, HttpResponse, web};
@@ -76,8 +76,8 @@ pub async fn images_add(
{
*project_id = Some(project.inner.id.into());
} else {
- return Err(ApiError::CustomAuthentication(
- "You are not authorized to upload images for this project".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::UploadProjectImages,
));
}
} else {
@@ -103,8 +103,8 @@ pub async fn images_add(
{
*version_id = Some(version.inner.id.into());
} else {
- return Err(ApiError::CustomAuthentication(
- "You are not authorized to upload images for this version".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::UploadVersionImages,
));
}
} else {
@@ -136,9 +136,8 @@ pub async fn images_add(
if is_authorized_thread(&thread, &user, &pool).await? {
*thread_message_id = Some(thread_message.id.into());
} else {
- return Err(ApiError::CustomAuthentication(
- "You are not authorized to upload images for this thread message"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::UploadThreadMessageImages,
));
}
}
@@ -162,8 +161,8 @@ pub async fn images_add(
if is_authorized_thread(&thread, &user, &pool).await? {
*report_id = Some(report.id.into());
} else {
- return Err(ApiError::CustomAuthentication(
- "You are not authorized to upload images for this report".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::UploadReportImages,
));
}
}
diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs
index 9b5040a9f0..05ae7085fe 100644
--- a/apps/labrinth/src/routes/v3/mod.rs
+++ b/apps/labrinth/src/routes/v3/mod.rs
@@ -1,10 +1,11 @@
-pub use super::ApiError;
+pub use crate::routes::error::ApiError;
use crate::util::cors::default_cors;
use actix_web::{HttpResponse, web};
use serde_json::json;
pub mod analytics_get;
pub mod collections;
+pub mod create_error;
pub mod friends;
pub mod images;
pub mod notifications;
diff --git a/apps/labrinth/src/routes/v3/notifications.rs b/apps/labrinth/src/routes/v3/notifications.rs
index 57898a567e..c3b866cdc4 100644
--- a/apps/labrinth/src/routes/v3/notifications.rs
+++ b/apps/labrinth/src/routes/v3/notifications.rs
@@ -5,7 +5,7 @@ use crate::models::ids::NotificationId;
use crate::models::notifications::Notification;
use crate::models::pats::Scopes;
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
use actix_web::{HttpRequest, HttpResponse, web};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
@@ -55,11 +55,7 @@ pub async fn notifications_get(
.collect();
let notifications_data: Vec =
- database::models::notification_item::DBNotification::get_many(
- ¬ification_ids,
- &**pool,
- )
- .await?;
+ DBNotification::get_many(¬ification_ids, &**pool).await?;
let notifications: Vec = notifications_data
.into_iter()
@@ -148,8 +144,8 @@ pub async fn notification_read(
Ok(HttpResponse::NoContent().body(""))
} else {
- Err(ApiError::CustomAuthentication(
- "You are not authorized to read this notification!".to_string(),
+ Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::NotificationRead,
))
}
} else {
@@ -198,9 +194,8 @@ pub async fn notification_delete(
Ok(HttpResponse::NoContent().body(""))
} else {
- Err(ApiError::CustomAuthentication(
- "You are not authorized to delete this notification!"
- .to_string(),
+ Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::NotificationDelete,
))
}
} else {
diff --git a/apps/labrinth/src/routes/v3/oauth_clients.rs b/apps/labrinth/src/routes/v3/oauth_clients.rs
index e738c7e521..20ad0ed8c7 100644
--- a/apps/labrinth/src/routes/v3/oauth_clients.rs
+++ b/apps/labrinth/src/routes/v3/oauth_clients.rs
@@ -1,8 +1,9 @@
use std::{collections::HashSet, fmt::Display, sync::Arc};
-use super::ApiError;
use crate::file_hosting::FileHostPublicity;
use crate::models::ids::OAuthClientId;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
+use crate::routes::v3::create_error::CreateError;
use crate::util::img::{delete_old_images, upload_image_optimized};
use crate::{
auth::{checks::ValidateAuthorized, get_user_from_headers},
@@ -21,7 +22,6 @@ use crate::{
pats::Scopes,
},
queue::session::AuthQueue,
- routes::v3::project_creation::CreateError,
util::validate::validation_errors_to_string,
};
use crate::{
@@ -80,8 +80,8 @@ pub async fn get_user_clients(
if target_user.id != current_user.id.into()
&& !current_user.role.is_admin()
{
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to see the OAuth clients of this user!".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::SeeOAuthClients,
));
}
diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs
index da726d8309..c121ffd1f0 100644
--- a/apps/labrinth/src/routes/v3/organizations.rs
+++ b/apps/labrinth/src/routes/v3/organizations.rs
@@ -1,7 +1,6 @@
use std::collections::HashMap;
use std::sync::Arc;
-use super::ApiError;
use crate::auth::{filter_visible_projects, get_user_from_headers};
use crate::database::models::team_item::DBTeamMember;
use crate::database::models::{
@@ -13,7 +12,8 @@ use crate::models::ids::OrganizationId;
use crate::models::pats::Scopes;
use crate::models::teams::{OrganizationPermissions, ProjectPermissions};
use crate::queue::session::AuthQueue;
-use crate::routes::v3::project_creation::CreateError;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
+use crate::routes::v3::create_error::{CreateError, CreationInvalidInput};
use crate::util::img::delete_old_images;
use crate::util::routes::read_limited_from_payload;
use crate::util::validate::validation_errors_to_string;
@@ -84,7 +84,7 @@ pub async fn organization_projects_get(
.try_collect::>()
.await?;
- let projects_data = crate::database::models::DBProject::get_many_ids(
+ let projects_data = database::models::DBProject::get_many_ids(
&project_ids,
&**pool,
&redis,
@@ -162,7 +162,7 @@ pub async fn organization_create(
let team = team_item::TeamBuilder {
members: vec![team_item::TeamMemberBuilder {
user_id: current_user.id.into(),
- role: crate::models::teams::DEFAULT_ROLE.to_owned(),
+ role: models::teams::DEFAULT_ROLE.to_owned(),
is_owner: true,
permissions: ProjectPermissions::all(),
organization_permissions: Some(OrganizationPermissions::all()),
@@ -194,14 +194,14 @@ pub async fn organization_create(
.into_iter()
.next();
let members_data = if let Some(member_data) = member_data {
- vec![crate::models::teams::TeamMember::from_model(
+ vec![models::teams::TeamMember::from_model(
member_data,
current_user.clone(),
false,
)]
} else {
return Err(CreateError::InvalidInput(
- "Failed to get created team.".to_owned(), // should never happen
+ CreationInvalidInput::FailedGettingNewTeam, // should never happen
));
};
@@ -237,7 +237,7 @@ pub async fn organization_get(
DBTeamMember::get_from_team_full(data.team_id, &**pool, &redis)
.await?;
- let users = crate::database::models::DBUser::get_many_ids(
+ let users = database::models::DBUser::get_many_ids(
&members_data.iter().map(|x| x.user_id).collect::>(),
&**pool,
&redis,
@@ -256,13 +256,13 @@ pub async fn organization_get(
.filter(|x| {
logged_in
|| x.accepted
- || user_id.is_some_and(
- |y: crate::database::models::DBUserId| y == x.user_id,
- )
+ || user_id.is_some_and(|y: database::models::DBUserId| {
+ y == x.user_id
+ })
})
.filter_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
- crate::models::teams::TeamMember::from(
+ models::teams::TeamMember::from(
data,
user.clone(),
!logged_in,
@@ -301,7 +301,7 @@ pub async fn organizations_get(
let teams_data =
DBTeamMember::get_from_team_full_many(&team_ids, &**pool, &redis)
.await?;
- let users = crate::database::models::DBUser::get_many_ids(
+ let users = database::models::DBUser::get_many_ids(
&teams_data.iter().map(|x| x.user_id).collect::>(),
&**pool,
&redis,
@@ -343,13 +343,13 @@ pub async fn organizations_get(
.filter(|x| {
logged_in
|| x.accepted
- || user_id.is_some_and(
- |y: crate::database::models::DBUserId| y == x.user_id,
- )
+ || user_id.is_some_and(|y: database::models::DBUserId| {
+ y == x.user_id
+ })
})
.filter_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
- crate::models::teams::TeamMember::from(
+ models::teams::TeamMember::from(
data,
user.clone(),
!logged_in,
@@ -402,12 +402,11 @@ pub async fn organizations_edit(
})?;
let string = info.into_inner().0;
- let result =
- database::models::DBOrganization::get(&string, &**pool, &redis).await?;
+ let result = DBOrganization::get(&string, &**pool, &redis).await?;
if let Some(organization_item) = result {
let id = organization_item.id;
- let team_member = database::models::DBTeamMember::get_from_user_id(
+ let team_member = DBTeamMember::get_from_user_id(
organization_item.team_id,
user.id.into(),
&**pool,
@@ -423,9 +422,8 @@ pub async fn organizations_edit(
let mut transaction = pool.begin().await?;
if let Some(description) = &new_organization.description {
if !perms.contains(OrganizationPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the description of this organization!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditOrgDescription,
));
}
sqlx::query!(
@@ -443,9 +441,8 @@ pub async fn organizations_edit(
if let Some(name) = &new_organization.name {
if !perms.contains(OrganizationPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the name of this organization!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditOrgName,
));
}
sqlx::query!(
@@ -463,9 +460,8 @@ pub async fn organizations_edit(
if let Some(slug) = &new_organization.slug {
if !perms.contains(OrganizationPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the slug of this organization!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditOrgSlug,
));
}
@@ -524,7 +520,7 @@ pub async fn organizations_edit(
}
transaction.commit().await?;
- database::models::DBOrganization::clear_cache(
+ DBOrganization::clear_cache(
organization_item.id,
Some(organization_item.slug),
&redis,
@@ -533,9 +529,8 @@ pub async fn organizations_edit(
Ok(HttpResponse::NoContent().body(""))
} else {
- Err(ApiError::CustomAuthentication(
- "You do not have permission to edit this organization!"
- .to_string(),
+ Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditOrg,
))
}
} else {
@@ -561,30 +556,28 @@ pub async fn organization_delete(
.1;
let string = info.into_inner().0;
- let organization =
- database::models::DBOrganization::get(&string, &**pool, &redis)
- .await?
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "The specified organization does not exist!".to_string(),
- )
- })?;
+ let organization = DBOrganization::get(&string, &**pool, &redis)
+ .await?
+ .ok_or_else(|| {
+ ApiError::InvalidInput(
+ "The specified organization does not exist!".to_string(),
+ )
+ })?;
if !user.role.is_admin() {
- let team_member =
- database::models::DBTeamMember::get_from_user_id_organization(
- organization.id,
- user.id.into(),
- false,
- &**pool,
+ let team_member = DBTeamMember::get_from_user_id_organization(
+ organization.id,
+ user.id.into(),
+ false,
+ &**pool,
+ )
+ .await
+ .map_err(ApiError::Database)?
+ .ok_or_else(|| {
+ ApiError::InvalidInput(
+ "The specified organization does not exist!".to_string(),
)
- .await
- .map_err(ApiError::Database)?
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "The specified organization does not exist!".to_string(),
- )
- })?;
+ })?;
let permissions = OrganizationPermissions::get_permissions_by_role(
&user.role,
@@ -593,9 +586,8 @@ pub async fn organization_delete(
.unwrap_or_default();
if !permissions.contains(OrganizationPermissions::DELETE_ORGANIZATION) {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to delete this organization!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::DeleteOrg,
));
}
}
@@ -632,10 +624,9 @@ pub async fn organization_delete(
.await?;
for organization_project_team in &organization_project_teams {
- let new_id = crate::database::models::ids::generate_team_member_id(
- &mut transaction,
- )
- .await?;
+ let new_id =
+ database::models::ids::generate_team_member_id(&mut transaction)
+ .await?;
let member = DBTeamMember {
id: new_id,
team_id: *organization_project_team,
@@ -651,16 +642,13 @@ pub async fn organization_delete(
member.insert(&mut transaction).await?;
}
// Safely remove the organization
- let result = database::models::DBOrganization::remove(
- organization.id,
- &mut transaction,
- &redis,
- )
- .await?;
+ let result =
+ DBOrganization::remove(organization.id, &mut transaction, &redis)
+ .await?;
transaction.commit().await?;
- database::models::DBOrganization::clear_cache(
+ DBOrganization::clear_cache(
organization.id,
Some(organization.slug),
&redis,
@@ -668,7 +656,7 @@ pub async fn organization_delete(
.await?;
for team_id in &organization_project_teams {
- database::models::DBTeamMember::clear_cache(*team_id, &redis).await?;
+ DBTeamMember::clear_cache(*team_id, &redis).await?;
}
if !organization_project_teams.is_empty() {
@@ -706,14 +694,13 @@ pub async fn organization_projects_add(
.await?
.1;
- let organization =
- database::models::DBOrganization::get(&info, &**pool, &redis)
- .await?
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "The specified organization does not exist!".to_string(),
- )
- })?;
+ let organization = DBOrganization::get(&info, &**pool, &redis)
+ .await?
+ .ok_or_else(|| {
+ ApiError::InvalidInput(
+ "The specified organization does not exist!".to_string(),
+ )
+ })?;
let project_item = database::models::DBProject::get(
&project_info.project_id,
@@ -733,37 +720,35 @@ pub async fn organization_projects_add(
));
}
- let project_team_member =
- database::models::DBTeamMember::get_from_user_id_project(
- project_item.inner.id,
- current_user.id.into(),
- false,
- &**pool,
+ let project_team_member = DBTeamMember::get_from_user_id_project(
+ project_item.inner.id,
+ current_user.id.into(),
+ false,
+ &**pool,
+ )
+ .await?
+ .ok_or_else(|| {
+ ApiError::InvalidInput(
+ "You are not a member of this project!".to_string(),
)
- .await?
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "You are not a member of this project!".to_string(),
- )
- })?;
- let organization_team_member =
- database::models::DBTeamMember::get_from_user_id_organization(
- organization.id,
- current_user.id.into(),
- false,
- &**pool,
+ })?;
+ let organization_team_member = DBTeamMember::get_from_user_id_organization(
+ organization.id,
+ current_user.id.into(),
+ false,
+ &**pool,
+ )
+ .await?
+ .ok_or_else(|| {
+ ApiError::InvalidInput(
+ "You are not a member of this organization!".to_string(),
)
- .await?
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "You are not a member of this organization!".to_string(),
- )
- })?;
+ })?;
// Require ownership of a project to add it to an organization
if !current_user.role.is_admin() && !project_team_member.is_owner {
- return Err(ApiError::CustomAuthentication(
- "You need to be an owner of a project to add it to an organization!".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::NotProjectOwnerForAddToOrg,
));
}
@@ -822,11 +807,7 @@ pub async fn organization_projects_add(
&redis,
)
.await?;
- database::models::DBTeamMember::clear_cache(
- project_item.inner.team_id,
- &redis,
- )
- .await?;
+ DBTeamMember::clear_cache(project_item.inner.team_id, &redis).await?;
database::models::DBProject::clear_cache(
project_item.inner.id,
project_item.inner.slug,
@@ -835,9 +816,8 @@ pub async fn organization_projects_add(
)
.await?;
} else {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to add projects to this organization!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::AddToOrg,
));
}
Ok(HttpResponse::Ok().finish())
@@ -869,17 +849,13 @@ pub async fn organization_projects_remove(
.await?
.1;
- let organization = database::models::DBOrganization::get(
- &organization_id,
- &**pool,
- &redis,
- )
- .await?
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "The specified organization does not exist!".to_string(),
- )
- })?;
+ let organization = DBOrganization::get(&organization_id, &**pool, &redis)
+ .await?
+ .ok_or_else(|| {
+ ApiError::InvalidInput(
+ "The specified organization does not exist!".to_string(),
+ )
+ })?;
let project_item =
database::models::DBProject::get(&project_id, &**pool, &redis)
@@ -901,19 +877,18 @@ pub async fn organization_projects_remove(
));
}
- let organization_team_member =
- database::models::DBTeamMember::get_from_user_id_organization(
- organization.id,
- current_user.id.into(),
- false,
- &**pool,
+ let organization_team_member = DBTeamMember::get_from_user_id_organization(
+ organization.id,
+ current_user.id.into(),
+ false,
+ &**pool,
+ )
+ .await?
+ .ok_or_else(|| {
+ ApiError::InvalidInput(
+ "You are not a member of this organization!".to_string(),
)
- .await?
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "You are not a member of this organization!".to_string(),
- )
- })?;
+ })?;
let permissions = OrganizationPermissions::get_permissions_by_role(
¤t_user.role,
@@ -922,7 +897,7 @@ pub async fn organization_projects_remove(
.unwrap_or_default();
if permissions.contains(OrganizationPermissions::REMOVE_PROJECT) {
// Now that permissions are confirmed, we confirm the veracity of the new user as an org member
- database::models::DBTeamMember::get_from_user_id_organization(
+ DBTeamMember::get_from_user_id_organization(
organization.id,
data.new_owner.into(),
false,
@@ -938,14 +913,13 @@ pub async fn organization_projects_remove(
// Then, we get the team member of the project and that user (if it exists)
// We use the team member get directly
- let new_owner =
- database::models::DBTeamMember::get_from_user_id_project(
- project_item.inner.id,
- data.new_owner.into(),
- true,
- &**pool,
- )
- .await?;
+ let new_owner = DBTeamMember::get_from_user_id_project(
+ project_item.inner.id,
+ data.new_owner.into(),
+ true,
+ &**pool,
+ )
+ .await?;
let mut transaction = pool.begin().await?;
@@ -953,11 +927,10 @@ pub async fn organization_projects_remove(
let new_owner = match new_owner {
Some(new_owner) => new_owner,
None => {
- let new_id =
- crate::database::models::ids::generate_team_member_id(
- &mut transaction,
- )
- .await?;
+ let new_id = database::models::ids::generate_team_member_id(
+ &mut transaction,
+ )
+ .await?;
let member = DBTeamMember {
id: new_id,
team_id: project_item.inner.team_id,
@@ -1010,11 +983,7 @@ pub async fn organization_projects_remove(
&redis,
)
.await?;
- database::models::DBTeamMember::clear_cache(
- project_item.inner.team_id,
- &redis,
- )
- .await?;
+ DBTeamMember::clear_cache(project_item.inner.team_id, &redis).await?;
database::models::DBProject::clear_cache(
project_item.inner.id,
project_item.inner.slug,
@@ -1023,9 +992,8 @@ pub async fn organization_projects_remove(
)
.await?;
} else {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to add projects to this organization!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::AddToOrg,
));
}
Ok(HttpResponse::Ok().finish())
@@ -1058,17 +1026,16 @@ pub async fn organization_icon_edit(
.1;
let string = info.into_inner().0;
- let organization_item =
- database::models::DBOrganization::get(&string, &**pool, &redis)
- .await?
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "The specified organization does not exist!".to_string(),
- )
- })?;
+ let organization_item = DBOrganization::get(&string, &**pool, &redis)
+ .await?
+ .ok_or_else(|| {
+ ApiError::InvalidInput(
+ "The specified organization does not exist!".to_string(),
+ )
+ })?;
if !user.role.is_mod() {
- let team_member = database::models::DBTeamMember::get_from_user_id(
+ let team_member = DBTeamMember::get_from_user_id(
organization_item.team_id,
user.id.into(),
&**pool,
@@ -1083,9 +1050,8 @@ pub async fn organization_icon_edit(
.unwrap_or_default();
if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to edit this organization's icon."
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditOrgIcon,
));
}
}
@@ -1134,7 +1100,7 @@ pub async fn organization_icon_edit(
.await?;
transaction.commit().await?;
- database::models::DBOrganization::clear_cache(
+ DBOrganization::clear_cache(
organization_item.id,
Some(organization_item.slug),
&redis,
@@ -1163,17 +1129,16 @@ pub async fn delete_organization_icon(
.1;
let string = info.into_inner().0;
- let organization_item =
- database::models::DBOrganization::get(&string, &**pool, &redis)
- .await?
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "The specified organization does not exist!".to_string(),
- )
- })?;
+ let organization_item = DBOrganization::get(&string, &**pool, &redis)
+ .await?
+ .ok_or_else(|| {
+ ApiError::InvalidInput(
+ "The specified organization does not exist!".to_string(),
+ )
+ })?;
if !user.role.is_mod() {
- let team_member = database::models::DBTeamMember::get_from_user_id(
+ let team_member = DBTeamMember::get_from_user_id(
organization_item.team_id,
user.id.into(),
&**pool,
@@ -1188,9 +1153,8 @@ pub async fn delete_organization_icon(
.unwrap_or_default();
if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to edit this organization's icon."
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditOrgIcon,
));
}
}
@@ -1218,7 +1182,7 @@ pub async fn delete_organization_icon(
transaction.commit().await?;
- database::models::DBOrganization::clear_cache(
+ DBOrganization::clear_cache(
organization_item.id,
Some(organization_item.slug),
&redis,
diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs
index 7535fcd121..073ebbb7de 100644
--- a/apps/labrinth/src/routes/v3/payouts.rs
+++ b/apps/labrinth/src/routes/v3/payouts.rs
@@ -8,7 +8,7 @@ use crate::models::pats::Scopes;
use crate::models::payouts::{PayoutMethodType, PayoutStatus};
use crate::queue::payouts::PayoutsQueue;
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::util::avalara1099;
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
use chrono::{DateTime, Duration, Utc};
diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs
index 8561b0a0bd..81fc1c4c8c 100644
--- a/apps/labrinth/src/routes/v3/project_creation.rs
+++ b/apps/labrinth/src/routes/v3/project_creation.rs
@@ -1,5 +1,5 @@
use super::version_creation::{InitialVersionData, try_create_version_fields};
-use crate::auth::{AuthenticationError, get_user_from_headers};
+use crate::auth::get_user_from_headers;
use crate::database::models::loader_fields::{
Loader, LoaderField, LoaderFieldEnumValue,
};
@@ -7,7 +7,6 @@ use crate::database::models::thread_item::ThreadBuilder;
use crate::database::models::{self, DBUser, image_item};
use crate::database::redis::RedisPool;
use crate::file_hosting::{FileHost, FileHostPublicity, FileHostingError};
-use crate::models::error::ApiError;
use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId};
use crate::models::images::{Image, ImageContext};
use crate::models::pats::Scopes;
@@ -18,137 +17,32 @@ use crate::models::projects::{
use crate::models::teams::{OrganizationPermissions, ProjectPermissions};
use crate::models::threads::ThreadType;
use crate::queue::session::AuthQueue;
-use crate::search::indexing::IndexingError;
+use crate::routes::v3::create_error::{
+ CreateError, CreationAuthenticationError, CreationInvalidInput,
+ MissingValuePart,
+};
use crate::util::img::upload_image_optimized;
use crate::util::routes::read_from_field;
use crate::util::validate::validation_errors_to_string;
use actix_multipart::{Field, Multipart};
-use actix_web::http::StatusCode;
use actix_web::web::{self, Data};
use actix_web::{HttpRequest, HttpResponse};
use ariadne::ids::UserId;
use ariadne::ids::base62_impl::to_base62;
use chrono::Utc;
use futures::stream::StreamExt;
-use image::ImageError;
use itertools::Itertools;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
-use thiserror::Error;
use validator::Validate;
-pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
+pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("project", web::post().to(project_create));
}
-#[derive(Error, Debug)]
-pub enum CreateError {
- #[error("Environment Error")]
- EnvError(#[from] dotenvy::Error),
- #[error("An unknown database error occurred")]
- SqlxDatabaseError(#[from] sqlx::Error),
- #[error("Database Error: {0}")]
- DatabaseError(#[from] models::DatabaseError),
- #[error("Indexing Error: {0}")]
- IndexingError(#[from] IndexingError),
- #[error("Error while parsing multipart payload: {0}")]
- MultipartError(#[from] actix_multipart::MultipartError),
- #[error("Error while parsing JSON: {0}")]
- SerDeError(#[from] serde_json::Error),
- #[error("Error while validating input: {0}")]
- ValidationError(String),
- #[error("Error while uploading file: {0}")]
- FileHostingError(#[from] FileHostingError),
- #[error("Error while validating uploaded file: {0}")]
- FileValidationError(#[from] crate::validate::ValidationError),
- #[error("{}", .0)]
- MissingValueError(String),
- #[error("Invalid format for image: {0}")]
- InvalidIconFormat(String),
- #[error("Error with multipart data: {0}")]
- InvalidInput(String),
- #[error("Invalid game version: {0}")]
- InvalidGameVersion(String),
- #[error("Invalid loader: {0}")]
- InvalidLoader(String),
- #[error("Invalid category: {0}")]
- InvalidCategory(String),
- #[error("Invalid file type for version file: {0}")]
- InvalidFileType(String),
- #[error("Slug is already taken!")]
- SlugCollision,
- #[error("Authentication Error: {0}")]
- Unauthorized(#[from] AuthenticationError),
- #[error("Authentication Error: {0}")]
- CustomAuthenticationError(String),
- #[error("Image Parsing Error: {0}")]
- ImageError(#[from] ImageError),
-}
-
-impl actix_web::ResponseError for CreateError {
- fn status_code(&self) -> StatusCode {
- match self {
- CreateError::EnvError(..) => StatusCode::INTERNAL_SERVER_ERROR,
- CreateError::SqlxDatabaseError(..) => {
- StatusCode::INTERNAL_SERVER_ERROR
- }
- CreateError::DatabaseError(..) => StatusCode::INTERNAL_SERVER_ERROR,
- CreateError::IndexingError(..) => StatusCode::INTERNAL_SERVER_ERROR,
- CreateError::FileHostingError(..) => {
- StatusCode::INTERNAL_SERVER_ERROR
- }
- CreateError::SerDeError(..) => StatusCode::BAD_REQUEST,
- CreateError::MultipartError(..) => StatusCode::BAD_REQUEST,
- CreateError::MissingValueError(..) => StatusCode::BAD_REQUEST,
- CreateError::InvalidIconFormat(..) => StatusCode::BAD_REQUEST,
- CreateError::InvalidInput(..) => StatusCode::BAD_REQUEST,
- CreateError::InvalidGameVersion(..) => StatusCode::BAD_REQUEST,
- CreateError::InvalidLoader(..) => StatusCode::BAD_REQUEST,
- CreateError::InvalidCategory(..) => StatusCode::BAD_REQUEST,
- CreateError::InvalidFileType(..) => StatusCode::BAD_REQUEST,
- CreateError::Unauthorized(..) => StatusCode::UNAUTHORIZED,
- CreateError::CustomAuthenticationError(..) => {
- StatusCode::UNAUTHORIZED
- }
- CreateError::SlugCollision => StatusCode::BAD_REQUEST,
- CreateError::ValidationError(..) => StatusCode::BAD_REQUEST,
- CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST,
- CreateError::ImageError(..) => StatusCode::BAD_REQUEST,
- }
- }
-
- fn error_response(&self) -> HttpResponse {
- HttpResponse::build(self.status_code()).json(ApiError {
- error: match self {
- CreateError::EnvError(..) => "environment_error",
- CreateError::SqlxDatabaseError(..) => "database_error",
- CreateError::DatabaseError(..) => "database_error",
- CreateError::IndexingError(..) => "indexing_error",
- CreateError::FileHostingError(..) => "file_hosting_error",
- CreateError::SerDeError(..) => "invalid_input",
- CreateError::MultipartError(..) => "invalid_input",
- CreateError::MissingValueError(..) => "invalid_input",
- CreateError::InvalidIconFormat(..) => "invalid_input",
- CreateError::InvalidInput(..) => "invalid_input",
- CreateError::InvalidGameVersion(..) => "invalid_input",
- CreateError::InvalidLoader(..) => "invalid_input",
- CreateError::InvalidCategory(..) => "invalid_input",
- CreateError::InvalidFileType(..) => "invalid_input",
- CreateError::Unauthorized(..) => "unauthorized",
- CreateError::CustomAuthenticationError(..) => "unauthorized",
- CreateError::SlugCollision => "invalid_input",
- CreateError::ValidationError(..) => "invalid_input",
- CreateError::FileValidationError(..) => "invalid_input",
- CreateError::ImageError(..) => "invalid_image",
- },
- description: self.to_string(),
- })
- }
-}
-
pub fn default_project_type() -> String {
"mod".to_string()
}
@@ -348,34 +242,29 @@ async fn project_create_inner(
let project_id: ProjectId =
models::generate_project_id(transaction).await?.into();
- let all_loaders =
- models::loader_fields::Loader::list(&mut **transaction, redis).await?;
+ let all_loaders = Loader::list(&mut **transaction, redis).await?;
let project_create_data: ProjectCreateData;
let mut versions;
- let mut versions_map = std::collections::HashMap::new();
+ let mut versions_map = HashMap::new();
let mut gallery_urls = Vec::new();
{
// The first multipart field must be named "data" and contain a
// JSON `ProjectCreateData` object.
let mut field = payload.next().await.map_or_else(
- || {
- Err(CreateError::MissingValueError(String::from(
- "No `data` field in multipart upload",
- )))
- },
+ || Err(CreateError::MissingValueError(MissingValuePart::DataField)),
|m| m.map_err(CreateError::MultipartError),
)?;
let name = field.name().ok_or_else(|| {
- CreateError::MissingValueError(String::from("Missing content name"))
+ CreateError::MissingValueError(MissingValuePart::ContentName)
})?;
if name != "data" {
- return Err(CreateError::InvalidInput(String::from(
- "`data` field must come before file fields",
- )));
+ return Err(CreateError::InvalidInput(
+ CreationInvalidInput::DataFieldOutOfOrder,
+ ));
}
let mut data = Vec::new();
@@ -387,7 +276,9 @@ async fn project_create_inner(
let create_data: ProjectCreateData = serde_json::from_slice(&data)?;
create_data.validate().map_err(|err| {
- CreateError::InvalidInput(validation_errors_to_string(err, None))
+ CreateError::InvalidInput(CreationInvalidInput::Validation(
+ validation_errors_to_string(err, None),
+ ))
})?;
let slug_project_id_option: Option =
@@ -434,9 +325,9 @@ async fn project_create_inner(
for name in &data.file_parts {
if versions_map.insert(name.to_owned(), i).is_some() {
// If the name is already used
- return Err(CreateError::InvalidInput(String::from(
- "Duplicate multipart field name",
- )));
+ return Err(CreateError::InvalidInput(
+ CreationInvalidInput::DuplicateMultipartField,
+ ));
}
}
versions.push(
@@ -466,10 +357,11 @@ async fn project_create_inner(
}
let result = async {
- let content_disposition = field.content_disposition().unwrap().clone();
+ let content_disposition =
+ field.content_disposition().unwrap().clone();
let name = content_disposition.get_name().ok_or_else(|| {
- CreateError::MissingValueError("Missing content name".to_string())
+ CreateError::MissingValueError(MissingValuePart::ContentName)
})?;
let (file_name, file_extension) =
@@ -477,9 +369,9 @@ async fn project_create_inner(
if name == "icon" {
if icon_data.is_some() {
- return Err(CreateError::InvalidInput(String::from(
- "Projects can only have one icon",
- )));
+ return Err(CreateError::InvalidInput(
+ CreationInvalidInput::MultipleIcons,
+ ));
}
// Upload the icon to the cdn
icon_data = Some(
@@ -496,20 +388,24 @@ async fn project_create_inner(
}
if let Some(gallery_items) = &project_create_data.gallery_items {
if gallery_items.iter().filter(|a| a.featured).count() > 1 {
- return Err(CreateError::InvalidInput(String::from(
- "Only one gallery image can be featured.",
- )));
+ return Err(CreateError::InvalidInput(
+ CreationInvalidInput::MultipleFeaturedGallery,
+ ));
}
- if let Some(item) = gallery_items.iter().find(|x| x.item == name) {
+ if let Some(item) =
+ gallery_items.iter().find(|x| x.item == name)
+ {
let data = read_from_field(
&mut field,
5 * (1 << 20),
- "Gallery image exceeds the maximum of 5MiB.",
+ CreationInvalidInput::GalleryImageTooLarge("5 MiB"),
)
.await?;
let (_, file_extension) =
- super::version_creation::get_name_ext(&content_disposition)?;
+ super::version_creation::get_name_ext(
+ &content_disposition,
+ )?;
let url = format!("data/{project_id}/images");
let upload_result = upload_image_optimized(
@@ -522,7 +418,7 @@ async fn project_create_inner(
file_host,
)
.await
- .map_err(|e| CreateError::InvalidIconFormat(e.to_string()))?;
+ .map_err(CreateError::InvalidIconFormat)?;
uploaded_files.push(UploadedFile {
name: upload_result.raw_url_path,
@@ -544,9 +440,15 @@ async fn project_create_inner(
let index = if let Some(i) = versions_map.get(name) {
*i
} else {
- return Err(CreateError::InvalidInput(format!(
- "File `{file_name}` (field {name}) isn't specified in the versions data"
- )));
+ // return Err(CreateError::InvalidInput(format!(
+ // "File `{file_name}` (field {name}) isn't specified in the versions data"
+ // )));
+ return Err(CreateError::InvalidInput(
+ CreationInvalidInput::FileNotSpecified(
+ file_name.to_string(),
+ name.to_string(),
+ ),
+ ));
};
// `index` is always valid for these lists
let created_version = &mut versions[index];
@@ -602,9 +504,9 @@ async fn project_create_inner(
.zip(versions.iter())
{
if version_data.file_parts.len() != builder.files.len() {
- return Err(CreateError::InvalidInput(String::from(
- "Some files were specified in initial_versions but not uploaded",
- )));
+ return Err(CreateError::InvalidInput(
+ CreationInvalidInput::InitialVersionsFilesMissing,
+ ));
}
}
@@ -653,7 +555,7 @@ async fn project_create_inner(
.await?
.ok_or_else(|| {
CreateError::InvalidInput(
- "Invalid organization ID specified!".to_string(),
+ CreationInvalidInput::InvalidOrganizationId,
)
})?;
@@ -672,9 +574,8 @@ async fn project_create_inner(
if !perms.is_some_and(|x| {
x.contains(OrganizationPermissions::ADD_PROJECT)
}) {
- return Err(CreateError::CustomAuthenticationError(
- "You do not have the permissions to create projects in this organization!"
- .to_string(),
+ return Err(CreateError::CreationAuthenticationError(
+ CreationAuthenticationError::CreateProjectsInOrganization,
));
}
} else {
@@ -699,20 +600,15 @@ async fn project_create_inner(
} else {
status = ProjectStatus::Processing;
if project_create_data.initial_versions.is_empty() {
- return Err(CreateError::InvalidInput(String::from(
- "Project submitted for review with no initial versions",
- )));
+ return Err(CreateError::InvalidInput(
+ CreationInvalidInput::NoInitialVersions,
+ ));
}
}
- let license_id = spdx::Expression::parse(
- &project_create_data.license_id,
- )
- .map_err(|err| {
- CreateError::InvalidInput(format!(
- "Invalid SPDX license identifier: {err}"
- ))
- })?;
+ let license_id =
+ spdx::Expression::parse(&project_create_data.license_id)
+ .map_err(|err| CreateError::InvalidInput(err.into()))?;
let mut link_urls = vec![];
@@ -726,19 +622,21 @@ async fn project_create_inner(
)
.await?
.ok_or_else(|| {
- CreateError::InvalidInput(format!(
- "Link platform {} does not exist.",
- platform.clone()
- ))
+ CreateError::InvalidInput(
+ CreationInvalidInput::NonexistentLinkPlatform(
+ platform.clone(),
+ ),
+ )
})?;
let link_platform = link_platforms
.iter()
.find(|x| x.id == platform_id)
.ok_or_else(|| {
- CreateError::InvalidInput(format!(
- "Link platform {} does not exist.",
- platform.clone()
- ))
+ CreateError::InvalidInput(
+ CreationInvalidInput::NonexistentLinkPlatform(
+ platform.clone(),
+ ),
+ )
})?;
link_urls.push(models::project_item::LinkUrl {
platform_id,
@@ -803,9 +701,11 @@ async fn project_create_inner(
if !matches!(image.context, ImageContext::Project { .. })
|| image.context.inner_id().is_some()
{
- return Err(CreateError::InvalidInput(format!(
- "Image {image_id} is not unused and in the 'project' context"
- )));
+ return Err(CreateError::InvalidInput(
+ CreationInvalidInput::ImproperContextImage(
+ image_id, "project",
+ ),
+ ));
}
sqlx::query!(
@@ -823,9 +723,9 @@ async fn project_create_inner(
image_item::DBImage::clear_cache(image.id.into(), redis)
.await?;
} else {
- return Err(CreateError::InvalidInput(format!(
- "Image {image_id} does not exist"
- )));
+ return Err(CreateError::InvalidInput(
+ CreationInvalidInput::NonexistentImage(image_id),
+ ));
}
}
@@ -915,14 +815,14 @@ async fn create_initial_version(
version_data: &InitialVersionData,
project_id: ProjectId,
author: UserId,
- all_loaders: &[models::loader_fields::Loader],
+ all_loaders: &[Loader],
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
redis: &RedisPool,
) -> Result {
if version_data.project_id.is_some() {
- return Err(CreateError::InvalidInput(String::from(
- "Found project id in initial version for new project",
- )));
+ return Err(CreateError::InvalidInput(
+ CreationInvalidInput::ProjectIdInInitialVersion,
+ ));
}
version_data.validate().map_err(|err| {
@@ -1004,10 +904,10 @@ async fn process_icon_upload(
let data = read_from_field(
&mut field,
262144,
- "Icons must be smaller than 256KiB",
+ CreationInvalidInput::IconTooLarge("256 KiB"),
)
.await?;
- let upload_result = crate::util::img::upload_image_optimized(
+ let upload_result = upload_image_optimized(
&format!("data/{}", to_base62(id)),
FileHostPublicity::Public,
data.freeze(),
@@ -1017,7 +917,7 @@ async fn process_icon_upload(
file_host,
)
.await
- .map_err(|e| CreateError::InvalidIconFormat(e.to_string()))?;
+ .map_err(CreateError::InvalidIconFormat)?;
uploaded_files.push(UploadedFile {
name: upload_result.raw_url_path,
diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs
index 43e67dff3d..aa372c9bba 100644
--- a/apps/labrinth/src/routes/v3/projects.rs
+++ b/apps/labrinth/src/routes/v3/projects.rs
@@ -23,7 +23,7 @@ use crate::models::teams::ProjectPermissions;
use crate::models::threads::MessageBody;
use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
use crate::search::indexing::remove_documents;
use crate::search::{SearchConfig, SearchError, search_for_project};
use crate::util::img;
@@ -107,7 +107,7 @@ pub async fn random_projects_get(
LIMIT $2",
&*crate::models::projects::ProjectStatus::iterator()
.filter(|x| x.is_searchable())
- .map(|x| x.to_string())
+ .map(|x| x.as_str().to_string())
.collect::>(),
count.count as i32,
)
@@ -289,7 +289,7 @@ pub async fn project_edit(
let id = project_item.inner.id;
let (team_member, organization_team_member) =
- db_models::DBTeamMember::get_for_project_permissions(
+ DBTeamMember::get_for_project_permissions(
&project_item.inner,
user.id.into(),
&**pool,
@@ -301,8 +301,8 @@ pub async fn project_edit(
&team_member,
&organization_team_member,
) else {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to edit this project!".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProject,
));
};
@@ -310,9 +310,8 @@ pub async fn project_edit(
if let Some(name) = &new_project.name {
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the name of this project!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectName,
));
}
@@ -331,9 +330,8 @@ pub async fn project_edit(
if let Some(summary) = &new_project.summary {
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the summary of this project!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectSummary,
));
}
@@ -352,9 +350,8 @@ pub async fn project_edit(
if let Some(status) = &new_project.status {
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the status of this project!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectStatus,
));
}
@@ -364,8 +361,8 @@ pub async fn project_edit(
|| project_item.inner.status.is_approved()
&& status.can_be_requested())
{
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to set this status!".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::RestrictedProjectStatus,
));
}
@@ -445,10 +442,9 @@ pub async fn project_edit(
dotenvy::var("SITE_URL")?,
user.username,
user.username,
- &project_item.inner.status.as_friendly_str(),
- status.as_friendly_str(),
- )
- .to_string(),
+ &project_item.inner.status,
+ status,
+ ),
),
)
.await
@@ -523,9 +519,8 @@ pub async fn project_edit(
if let Some(requested_status) = &new_project.requested_status {
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the requested status of this project!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectRequestedStatus,
));
}
@@ -581,7 +576,7 @@ pub async fn project_edit(
edit_project_categories(
categories,
&perms,
- id as db_ids::DBProjectId,
+ id,
false,
&mut transaction,
)
@@ -589,21 +584,14 @@ pub async fn project_edit(
}
if let Some(categories) = &new_project.additional_categories {
- edit_project_categories(
- categories,
- &perms,
- id as db_ids::DBProjectId,
- true,
- &mut transaction,
- )
- .await?;
+ edit_project_categories(categories, &perms, id, true, &mut transaction)
+ .await?;
}
if let Some(license_url) = &new_project.license_url {
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the license URL of this project!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectLicenseUrl,
));
}
@@ -622,9 +610,8 @@ pub async fn project_edit(
if let Some(slug) = &new_project.slug {
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the slug of this project!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectSlug,
));
}
@@ -680,9 +667,8 @@ pub async fn project_edit(
if let Some(license) = &new_project.license_id {
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the license of this project!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectLicense,
));
}
@@ -715,10 +701,9 @@ pub async fn project_edit(
&& !links.is_empty()
{
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the links of this project!"
- .to_string(),
- ));
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectLinks,
+ ));
}
let ids_to_delete = links.keys().cloned().collect::>();
@@ -768,9 +753,8 @@ pub async fn project_edit(
&& (!project_item.inner.status.is_approved()
|| moderation_message.is_some())
{
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the moderation message of this project!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectModerationMessage,
));
}
@@ -793,9 +777,8 @@ pub async fn project_edit(
&& (!project_item.inner.status.is_approved()
|| moderation_message_body.is_some())
{
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the moderation message body of this project!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectModerationMessageBody,
));
}
@@ -814,9 +797,8 @@ pub async fn project_edit(
if let Some(description) = &new_project.description {
if !perms.contains(ProjectPermissions::EDIT_BODY) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the description (body) of this project!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectDescription,
));
}
@@ -835,9 +817,8 @@ pub async fn project_edit(
if let Some(monetization_status) = &new_project.monetization_status {
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the monetization status of this project!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectMonetizationStatus,
));
}
@@ -846,9 +827,8 @@ pub async fn project_edit(
== MonetizationStatus::ForceDemonetized)
&& !user.role.is_mod()
{
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the monetization status of this project!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectMonetizationStatus,
));
}
@@ -869,9 +849,8 @@ pub async fn project_edit(
&new_project.side_types_migration_review_status
{
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the side types migration review status of this project!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectSideTypesMigrationReviewStatus,
));
}
@@ -977,10 +956,11 @@ pub async fn edit_project_categories(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), ApiError> {
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
- let additional_str = if is_additional { "additional " } else { "" };
- return Err(ApiError::CustomAuthentication(format!(
- "You do not have the permissions to edit the {additional_str}categories of this project!"
- )));
+ return Err(ApiError::SpecificAuthentication(if is_additional {
+ SpecificAuthenticationError::EditProjectAdditionalCategories
+ } else {
+ SpecificAuthenticationError::EditProjectCategories
+ }));
}
let mut mod_categories = Vec::new();
@@ -1225,10 +1205,9 @@ pub async fn projects_edit(
.iter()
.map(|x| x.inner.team_id)
.collect::>();
- let team_members = db_models::DBTeamMember::get_from_team_full_many(
- &team_ids, &**pool, &redis,
- )
- .await?;
+ let team_members =
+ DBTeamMember::get_from_team_full_many(&team_ids, &**pool, &redis)
+ .await?;
let organization_ids = projects_data
.iter()
@@ -1245,13 +1224,12 @@ pub async fn projects_edit(
.iter()
.map(|x| x.team_id)
.collect::>();
- let organization_team_members =
- db_models::DBTeamMember::get_from_team_full_many(
- &organization_team_ids,
- &**pool,
- &redis,
- )
- .await?;
+ let organization_team_members = DBTeamMember::get_from_team_full_many(
+ &organization_team_ids,
+ &**pool,
+ &redis,
+ )
+ .await?;
let categories =
db_models::categories::Category::list(&**pool, &redis).await?;
@@ -1291,10 +1269,11 @@ pub async fn projects_edit(
if team_member.is_some() {
if !permissions.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(format!(
- "You do not have the permissions to bulk edit project {}!",
- project.inner.name
- )));
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::BulkEditProject(
+ project.inner.name,
+ ),
+ ));
}
} else if project.inner.status.is_hidden() {
return Err(ApiError::InvalidInput(format!(
@@ -1302,17 +1281,18 @@ pub async fn projects_edit(
ProjectId(project.inner.id.0 as u64)
)));
} else {
- return Err(ApiError::CustomAuthentication(format!(
- "You are not a member of project {}!",
- project.inner.name
- )));
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::NotMemberOfProject(
+ project.inner.name,
+ ),
+ ));
};
}
bulk_edit_project_categories(
&categories,
&project.categories,
- project.inner.id as db_ids::DBProjectId,
+ project.inner.id,
CategoryChanges {
categories: &bulk_edit_project.categories,
add_categories: &bulk_edit_project.add_categories,
@@ -1327,7 +1307,7 @@ pub async fn projects_edit(
bulk_edit_project_categories(
&categories,
&project.additional_categories,
- project.inner.id as db_ids::DBProjectId,
+ project.inner.id,
CategoryChanges {
categories: &bulk_edit_project.additional_categories,
add_categories: &bulk_edit_project.add_additional_categories,
@@ -1498,6 +1478,8 @@ pub async fn project_icon_edit(
let project_item = db_models::DBProject::get(&string, &**pool, &redis)
.await?
.ok_or_else(|| {
+ // FIXME: Change this to a 404 but also make sure it doesn't break existing clients
+ // (also applies to several other routes)
ApiError::InvalidInput(
"The specified project does not exist!".to_string(),
)
@@ -1505,7 +1487,7 @@ pub async fn project_icon_edit(
if !user.role.is_mod() {
let (team_member, organization_team_member) =
- db_models::DBTeamMember::get_for_project_permissions(
+ DBTeamMember::get_for_project_permissions(
&project_item.inner,
user.id.into(),
&**pool,
@@ -1514,7 +1496,7 @@ pub async fn project_icon_edit(
// Hide the project
if team_member.is_none() && organization_team_member.is_none() {
- return Err(ApiError::CustomAuthentication(
+ return Err(ApiError::InvalidInput(
"The specified project does not exist!".to_string(),
));
}
@@ -1527,9 +1509,8 @@ pub async fn project_icon_edit(
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to edit this project's icon."
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectIcon,
));
}
}
@@ -1618,7 +1599,7 @@ pub async fn delete_project_icon(
if !user.role.is_mod() {
let (team_member, organization_team_member) =
- db_models::DBTeamMember::get_for_project_permissions(
+ DBTeamMember::get_for_project_permissions(
&project_item.inner,
user.id.into(),
&**pool,
@@ -1627,7 +1608,7 @@ pub async fn delete_project_icon(
// Hide the project
if team_member.is_none() && organization_team_member.is_none() {
- return Err(ApiError::CustomAuthentication(
+ return Err(ApiError::InvalidInput(
"The specified project does not exist!".to_string(),
));
}
@@ -1639,9 +1620,8 @@ pub async fn delete_project_icon(
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to edit this project's icon."
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectIcon,
));
}
}
@@ -1724,16 +1704,9 @@ pub async fn add_gallery_item(
)
})?;
- if project_item.gallery_items.len() > 64 {
- return Err(ApiError::CustomAuthentication(
- "You have reached the maximum of gallery images to upload."
- .to_string(),
- ));
- }
-
if !user.role.is_admin() {
let (team_member, organization_team_member) =
- db_models::DBTeamMember::get_for_project_permissions(
+ DBTeamMember::get_for_project_permissions(
&project_item.inner,
user.id.into(),
&**pool,
@@ -1742,11 +1715,17 @@ pub async fn add_gallery_item(
// Hide the project
if team_member.is_none() && organization_team_member.is_none() {
- return Err(ApiError::CustomAuthentication(
+ return Err(ApiError::InvalidInput(
"The specified project does not exist!".to_string(),
));
}
+ if project_item.gallery_items.len() > 64 {
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::MaximumGalleryImages,
+ ));
+ }
+
let permissions = ProjectPermissions::get_permissions_by_role(
&user.role,
&team_member,
@@ -1755,9 +1734,8 @@ pub async fn add_gallery_item(
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to edit this project's gallery."
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectGallery,
));
}
}
@@ -1807,7 +1785,7 @@ pub async fn add_gallery_item(
.await?;
}
- let gallery_item = vec![db_models::project_item::DBGalleryItem {
+ let gallery_item = vec![DBGalleryItem {
image_url: upload_result.url,
raw_image_url: upload_result.raw_url,
featured: item.featured,
@@ -1908,7 +1886,7 @@ pub async fn edit_gallery_item(
if !user.role.is_mod() {
let (team_member, organization_team_member) =
- db_models::DBTeamMember::get_for_project_permissions(
+ DBTeamMember::get_for_project_permissions(
&project_item.inner,
user.id.into(),
&**pool,
@@ -1917,7 +1895,7 @@ pub async fn edit_gallery_item(
// Hide the project
if team_member.is_none() && organization_team_member.is_none() {
- return Err(ApiError::CustomAuthentication(
+ return Err(ApiError::InvalidInput(
"The specified project does not exist!".to_string(),
));
}
@@ -1929,9 +1907,8 @@ pub async fn edit_gallery_item(
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to edit this project's gallery."
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectGallery,
));
}
}
@@ -2071,7 +2048,7 @@ pub async fn delete_gallery_item(
if !user.role.is_mod() {
let (team_member, organization_team_member) =
- db_models::DBTeamMember::get_for_project_permissions(
+ DBTeamMember::get_for_project_permissions(
&project_item.inner,
user.id.into(),
&**pool,
@@ -2080,7 +2057,7 @@ pub async fn delete_gallery_item(
// Hide the project
if team_member.is_none() && organization_team_member.is_none() {
- return Err(ApiError::CustomAuthentication(
+ return Err(ApiError::InvalidInput(
"The specified project does not exist!".to_string(),
));
}
@@ -2093,9 +2070,8 @@ pub async fn delete_gallery_item(
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::EDIT_DETAILS) {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to edit this project's gallery."
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditProjectGallery,
));
}
}
@@ -2162,7 +2138,7 @@ pub async fn project_delete(
if !user.role.is_admin() {
let (team_member, organization_team_member) =
- db_models::DBTeamMember::get_for_project_permissions(
+ DBTeamMember::get_for_project_permissions(
&project.inner,
user.id.into(),
&**pool,
@@ -2171,7 +2147,7 @@ pub async fn project_delete(
// Hide the project
if team_member.is_none() && organization_team_member.is_none() {
- return Err(ApiError::CustomAuthentication(
+ return Err(ApiError::InvalidInput(
"The specified project does not exist!".to_string(),
));
}
@@ -2184,8 +2160,8 @@ pub async fn project_delete(
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::DELETE_PROJECT) {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to delete this project!".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::DeleteProject,
));
}
}
@@ -2442,7 +2418,7 @@ pub async fn project_get_organization(
)
.await?;
- let users = crate::database::models::DBUser::get_many_ids(
+ let users = database::models::DBUser::get_many_ids(
&members_data.iter().map(|x| x.user_id).collect::>(),
&**pool,
&redis,
@@ -2461,13 +2437,13 @@ pub async fn project_get_organization(
.filter(|x| {
logged_in
|| x.accepted
- || user_id.is_some_and(
- |y: crate::database::models::DBUserId| y == x.user_id,
- )
+ || user_id.is_some_and(|y: database::models::DBUserId| {
+ y == x.user_id
+ })
})
.filter_map(|data| {
users.iter().find(|x| x.id == data.user_id).map(|user| {
- crate::models::teams::TeamMember::from(
+ models::teams::TeamMember::from(
data,
user.clone(),
!logged_in,
diff --git a/apps/labrinth/src/routes/v3/reports.rs b/apps/labrinth/src/routes/v3/reports.rs
index 03346201c1..97599bdbe6 100644
--- a/apps/labrinth/src/routes/v3/reports.rs
+++ b/apps/labrinth/src/routes/v3/reports.rs
@@ -14,7 +14,7 @@ use crate::models::pats::Scopes;
use crate::models::reports::{ItemType, Report};
use crate::models::threads::{MessageBody, ThreadType};
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::util::img;
use crate::util::routes::read_typed_from_payload;
use actix_web::{HttpRequest, HttpResponse, web};
diff --git a/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs b/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs
index aaf35b6fb8..bbbe729c43 100644
--- a/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs
+++ b/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs
@@ -14,7 +14,7 @@ use crate::models::shared_instances::{
SharedInstanceUserPermissions, SharedInstanceVersion,
};
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
use crate::routes::v3::project_creation::UploadedFile;
use crate::util::ext::MRPACK_MIME_TYPE;
use actix_web::http::header::ContentLength;
@@ -127,8 +127,8 @@ async fn shared_instance_version_create_inner(
if !permissions
.contains(SharedInstanceUserPermissions::UPLOAD_VERSION)
{
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to upload a version for this shared instance.".to_string()
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::SharedInstanceUploadVersion,
));
}
} else {
diff --git a/apps/labrinth/src/routes/v3/shared_instances.rs b/apps/labrinth/src/routes/v3/shared_instances.rs
index fa1fc402ef..f885e3ef2c 100644
--- a/apps/labrinth/src/routes/v3/shared_instances.rs
+++ b/apps/labrinth/src/routes/v3/shared_instances.rs
@@ -15,7 +15,7 @@ use crate::models::shared_instances::{
};
use crate::models::users::User;
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
use crate::util::routes::read_typed_from_payload;
use actix_web::web::{Data, Redirect};
use actix_web::{HttpRequest, HttpResponse, web};
@@ -257,9 +257,8 @@ pub async fn shared_instance_edit(
.await?;
if let Some(permissions) = permissions {
if !permissions.contains(SharedInstanceUserPermissions::EDIT) {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to edit this shared instance."
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditSharedInstance,
));
}
} else {
@@ -332,8 +331,8 @@ pub async fn shared_instance_delete(
.await?;
if let Some(permissions) = permissions {
if !permissions.contains(SharedInstanceUserPermissions::DELETE) {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to delete this shared instance.".to_string()
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::DeleteSharedInstance,
));
}
} else {
@@ -503,8 +502,8 @@ pub async fn shared_instance_version_delete(
if !permissions
.contains(SharedInstanceUserPermissions::DELETE)
{
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to delete this shared instance version.".to_string()
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::DeleteSharedInstanceVersion
));
}
} else {
diff --git a/apps/labrinth/src/routes/v3/statistics.rs b/apps/labrinth/src/routes/v3/statistics.rs
index 169ff000c4..ced8a10d8e 100644
--- a/apps/labrinth/src/routes/v3/statistics.rs
+++ b/apps/labrinth/src/routes/v3/statistics.rs
@@ -1,4 +1,4 @@
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use actix_web::{HttpResponse, web};
use sqlx::PgPool;
@@ -25,7 +25,7 @@ pub async fn get_stats(
",
&*crate::models::projects::ProjectStatus::iterator()
.filter(|x| x.is_searchable())
- .map(|x| x.to_string())
+ .map(|x| x.as_str().to_string())
.collect::>(),
)
.fetch_one(&**pool)
@@ -40,7 +40,7 @@ pub async fn get_stats(
",
&*crate::models::projects::ProjectStatus::iterator()
.filter(|x| x.is_searchable())
- .map(|x| x.to_string())
+ .map(|x| x.as_str().to_string())
.collect::>(),
&*crate::models::projects::VersionStatus::iterator()
.filter(|x| x.is_listed())
@@ -59,7 +59,7 @@ pub async fn get_stats(
",
&*crate::models::projects::ProjectStatus::iterator()
.filter(|x| x.is_searchable())
- .map(|x| x.to_string())
+ .map(|x| x.as_str().to_string())
.collect::>(),
)
.fetch_one(&**pool)
@@ -73,7 +73,7 @@ pub async fn get_stats(
",
&*crate::models::projects::ProjectStatus::iterator()
.filter(|x| x.is_searchable())
- .map(|x| x.to_string())
+ .map(|x| x.as_str().to_string())
.collect::>(),
&*crate::models::projects::VersionStatus::iterator()
.filter(|x| x.is_listed())
diff --git a/apps/labrinth/src/routes/v3/tags.rs b/apps/labrinth/src/routes/v3/tags.rs
index fff7898918..bfbf072f59 100644
--- a/apps/labrinth/src/routes/v3/tags.rs
+++ b/apps/labrinth/src/routes/v3/tags.rs
@@ -1,6 +1,5 @@
use std::collections::HashMap;
-use super::ApiError;
use crate::database::models::categories::{
Category, LinkPlatform, ProjectType, ReportType,
};
@@ -8,6 +7,7 @@ use crate::database::models::loader_fields::{
Game, Loader, LoaderField, LoaderFieldEnumValue, LoaderFieldType,
};
use crate::database::redis::RedisPool;
+use crate::routes::error::ApiError;
use actix_web::{HttpResponse, web};
use itertools::Itertools;
diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs
index 0c8ca198a7..ba6b6c3e26 100644
--- a/apps/labrinth/src/routes/v3/teams.rs
+++ b/apps/labrinth/src/routes/v3/teams.rs
@@ -10,7 +10,7 @@ use crate::models::notifications::NotificationBody;
use crate::models::pats::Scopes;
use crate::models::teams::{OrganizationPermissions, ProjectPermissions};
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
use actix_web::{HttpRequest, HttpResponse, web};
use ariadne::ids::UserId;
use rust_decimal::Decimal;
@@ -47,9 +47,7 @@ pub async fn team_members_get_project(
session_queue: web::Data,
) -> Result {
let string = info.into_inner().0;
- let project_data =
- crate::database::models::DBProject::get(&string, &**pool, &redis)
- .await?;
+ let project_data = DBProject::get(&string, &**pool, &redis).await?;
if let Some(project) = project_data {
let current_user = get_user_from_headers(
@@ -131,8 +129,7 @@ pub async fn team_members_get_organization(
) -> Result {
let string = info.into_inner().0;
let organization_data =
- crate::database::models::DBOrganization::get(&string, &**pool, &redis)
- .await?;
+ DBOrganization::get(&string, &**pool, &redis).await?;
if let Some(organization) = organization_data {
let current_user = get_user_from_headers(
@@ -152,7 +149,7 @@ pub async fn team_members_get_organization(
&redis,
)
.await?;
- let users = crate::database::models::DBUser::get_many_ids(
+ let users = DBUser::get_many_ids(
&members_data.iter().map(|x| x.user_id).collect::>(),
&**pool,
&redis,
@@ -206,7 +203,7 @@ pub async fn team_members_get(
let id = info.into_inner().0;
let members_data =
DBTeamMember::get_from_team_full(id.into(), &**pool, &redis).await?;
- let users = crate::database::models::DBUser::get_many_ids(
+ let users = DBUser::get_many_ids(
&members_data.iter().map(|x| x.user_id).collect::>(),
&**pool,
&redis,
@@ -278,7 +275,7 @@ pub async fn teams_get(
let teams_data =
DBTeamMember::get_from_team_full_many(&team_ids, &**pool, &redis)
.await?;
- let users = crate::database::models::DBUser::get_many_ids(
+ let users = DBUser::get_many_ids(
&teams_data.iter().map(|x| x.user_id).collect::>(),
&**pool,
&redis,
@@ -477,9 +474,8 @@ pub async fn add_team_member(
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::MANAGE_INVITES) {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to invite users to this team"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::InviteUsersToTeam,
));
}
if !permissions.contains(new_member.permissions) {
@@ -507,8 +503,8 @@ pub async fn add_team_member(
if !organization_permissions
.contains(OrganizationPermissions::MANAGE_INVITES)
{
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to invite users to this organization".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::InviteUsersToOrg,
));
}
if !organization_permissions.contains(
@@ -522,9 +518,8 @@ pub async fn add_team_member(
OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS,
) && !new_member.permissions.is_empty()
{
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to give this user default project permissions. Ensure 'permissions' is set if it is not, and empty (0)."
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::GiveUserDefaultProjectPermissions,
));
}
}
@@ -557,15 +552,11 @@ pub async fn add_team_member(
));
}
}
- let new_user = crate::database::models::DBUser::get_id(
- new_member.user_id.into(),
- &**pool,
- &redis,
- )
- .await?
- .ok_or_else(|| {
- ApiError::InvalidInput("An invalid User ID specified".to_string())
- })?;
+ let new_user = DBUser::get_id(new_member.user_id.into(), &**pool, &redis)
+ .await?
+ .ok_or_else(|| {
+ ApiError::InvalidInput("An invalid User ID specified".to_string())
+ })?;
let mut force_accepted = false;
if let TeamAssociationId::Project(pid) = team_association {
@@ -705,9 +696,8 @@ pub async fn edit_team_member(
DBTeamMember::get_from_user_id_pending(id, user_id, &**pool)
.await?
.ok_or_else(|| {
- ApiError::CustomAuthentication(
- "You don't have permission to edit members of this team"
- .to_string(),
+ ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditTeamMembers,
)
})?;
@@ -748,9 +738,8 @@ pub async fn edit_team_member(
.permissions
.is_some_and(|x| x != ProjectPermissions::all())
{
- return Err(ApiError::CustomAuthentication(
- "You cannot override the project permissions of the organization owner!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::OverrideOrgOwnerDefaultProjectPermissions,
));
}
@@ -761,9 +750,8 @@ pub async fn edit_team_member(
)
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::EDIT_MEMBER) {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to edit members of this team"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditTeamMembers,
));
}
@@ -794,9 +782,8 @@ pub async fn edit_team_member(
if !organization_permissions
.contains(OrganizationPermissions::EDIT_MEMBER)
{
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to edit members of this team"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditTeamMembers,
));
}
@@ -814,9 +801,8 @@ pub async fn edit_team_member(
OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS,
)
{
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to give this user default project permissions."
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::GiveUserDefaultProjectPermissions,
));
}
}
@@ -901,16 +887,14 @@ pub async fn transfer_ownership(
)
.await?
.ok_or_else(|| {
- ApiError::CustomAuthentication(
- "You don't have permission to edit members of this team"
- .to_string(),
+ ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditTeamMembers,
)
})?;
if !member.is_owner {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to edit the ownership of this team"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditTeamOwnership,
));
}
}
@@ -1078,8 +1062,8 @@ pub async fn remove_team_member(
if let Some(delete_member) = delete_member {
if delete_member.is_owner {
// The owner cannot be removed from a team
- return Err(ApiError::CustomAuthentication(
- "The owner can't be removed from a team".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::RemoveOwnerFromTeam,
));
}
@@ -1123,9 +1107,8 @@ pub async fn remove_team_member(
DBTeamMember::delete(id, user_id, &mut transaction)
.await?;
} else {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to remove a member from this team"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::RemoveTeamMember,
));
}
} else if Some(delete_member.user_id)
@@ -1138,9 +1121,8 @@ pub async fn remove_team_member(
// permission can remove it.
DBTeamMember::delete(id, user_id, &mut transaction).await?;
} else {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to cancel a team invite"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::CancelTeamInvite,
));
}
}
@@ -1162,9 +1144,8 @@ pub async fn remove_team_member(
DBTeamMember::delete(id, user_id, &mut transaction)
.await?;
} else {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to remove a member from this organization"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::RemoveOrgMember,
));
}
} else if Some(delete_member.user_id)
@@ -1177,8 +1158,8 @@ pub async fn remove_team_member(
// permission can remove it.
DBTeamMember::delete(id, user_id, &mut transaction).await?;
} else {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to cancel an organization invite".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::CancelOrgInvite,
));
}
}
diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs
index 1a55f7b797..e1cd2e6593 100644
--- a/apps/labrinth/src/routes/v3/threads.rs
+++ b/apps/labrinth/src/routes/v3/threads.rs
@@ -15,7 +15,7 @@ use crate::models::projects::ProjectStatus;
use crate::models::threads::{MessageBody, Thread, ThreadType};
use crate::models::users::User;
use crate::queue::session::AuthQueue;
-use crate::routes::ApiError;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
use actix_web::{HttpRequest, HttpResponse, web};
use futures::TryStreamExt;
use serde::Deserialize;
@@ -602,8 +602,8 @@ pub async fn message_delete(
if let Some(thread) = result {
if !user.role.is_mod() && thread.author_id != Some(user.id.into()) {
- return Err(ApiError::CustomAuthentication(
- "You cannot delete this message!".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::DeleteMessage,
));
}
diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs
index f0e1a07085..ec81c8a5db 100644
--- a/apps/labrinth/src/routes/v3/users.rs
+++ b/apps/labrinth/src/routes/v3/users.rs
@@ -1,6 +1,7 @@
use std::{collections::HashMap, sync::Arc};
-use super::{ApiError, oauth_clients::get_user_clients};
+use super::oauth_clients::get_user_clients;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
use crate::{
auth::{
filter_visible_collections, filter_visible_projects,
@@ -70,9 +71,8 @@ pub async fn admin_user_email(
.map(|x| x.1)?;
if !user.role.is_admin() {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to get a user from their email!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::GetUserFromEmail,
));
}
@@ -443,9 +443,8 @@ pub async fn user_edit(
if let Some(role) = &new_user.role {
if !user.role.is_admin() {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the role of this user!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditUserRole,
));
}
@@ -466,9 +465,8 @@ pub async fn user_edit(
if let Some(badges) = &new_user.badges {
if !user.role.is_admin() {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the badges of this user!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditUserBadges,
));
}
@@ -487,9 +485,8 @@ pub async fn user_edit(
if let Some(venmo_handle) = &new_user.venmo_handle {
if !scopes.contains(Scopes::PAYOUTS_WRITE) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit the venmo handle of this user!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditUserVenmoHandle,
));
}
@@ -525,8 +522,8 @@ pub async fn user_edit(
.await?;
Ok(HttpResponse::NoContent().body(""))
} else {
- Err(ApiError::CustomAuthentication(
- "You do not have permission to edit this user!".to_string(),
+ Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditUser,
))
}
} else {
@@ -563,9 +560,8 @@ pub async fn user_icon_edit(
if let Some(actual_user) = id_option {
if user.id != actual_user.id.into() && !user.role.is_mod() {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to edit this user's icon."
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditUserIcon,
));
}
@@ -637,9 +633,8 @@ pub async fn user_icon_delete(
if let Some(actual_user) = id_option {
if user.id != actual_user.id.into() && !user.role.is_mod() {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to edit this user's icon."
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditUserIcon,
));
}
@@ -690,8 +685,8 @@ pub async fn user_delete(
if let Some(id) = id_option.map(|x| x.id) {
if !user.role.is_admin() && user.id != id.into() {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to delete this user!".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::DeleteUser,
));
}
@@ -731,8 +726,8 @@ pub async fn user_follows(
if let Some(id) = id_option.map(|x| x.id) {
if !user.role.is_admin() && user.id != id.into() {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to see the projects this user follows!".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::SeeUserFollows,
));
}
@@ -773,8 +768,8 @@ pub async fn user_notifications(
if let Some(id) = id_option.map(|x| x.id) {
if !user.role.is_admin() && user.id != id.into() {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to see the notifications of this user!".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::SeeUserNotifications,
));
}
diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs
index dd49340e26..a40cad50aa 100644
--- a/apps/labrinth/src/routes/v3/version_creation.rs
+++ b/apps/labrinth/src/routes/v3/version_creation.rs
@@ -1,4 +1,4 @@
-use super::project_creation::{CreateError, UploadedFile};
+use super::project_creation::UploadedFile;
use crate::auth::get_user_from_headers;
use crate::database::models::loader_fields::{
LoaderField, LoaderFieldEnumValue, VersionField,
@@ -23,6 +23,10 @@ use crate::models::projects::{DependencyType, ProjectStatus, skip_nulls};
use crate::models::teams::ProjectPermissions;
use crate::queue::moderation::AutomatedModerationQueue;
use crate::queue::session::AuthQueue;
+use crate::routes::v3::create_error::{
+ CreateError, CreationAuthenticationError, CreationInvalidInput,
+ MissingValuePart,
+};
use crate::util::routes::read_from_field;
use crate::util::validate::validation_errors_to_string;
use crate::validate::{ValidationResult, validate_file};
@@ -109,7 +113,7 @@ pub async fn version_create(
redis: Data,
file_host: Data>,
session_queue: Data,
- moderation_queue: web::Data,
+ moderation_queue: Data,
) -> Result {
let mut transaction = client.begin().await?;
let mut uploaded_files = Vec::new();
@@ -183,9 +187,10 @@ async fn version_create_inner(
}
let result = async {
- let content_disposition = field.content_disposition().unwrap().clone();
+ let content_disposition =
+ field.content_disposition().unwrap().clone();
let name = content_disposition.get_name().ok_or_else(|| {
- CreateError::MissingValueError("Missing content name".to_string())
+ CreateError::MissingValueError(MissingValuePart::ContentName)
})?;
if name == "data" {
@@ -194,64 +199,78 @@ async fn version_create_inner(
data.extend_from_slice(&chunk?);
}
- let version_create_data: InitialVersionData = serde_json::from_slice(&data)?;
+ let version_create_data: InitialVersionData =
+ serde_json::from_slice(&data)?;
initial_version_data = Some(version_create_data);
- let version_create_data = initial_version_data.as_ref().unwrap();
+ let version_create_data =
+ initial_version_data.as_ref().unwrap();
if version_create_data.project_id.is_none() {
return Err(CreateError::MissingValueError(
- "Missing project id".to_string(),
+ MissingValuePart::ProjectId,
));
}
version_create_data.validate().map_err(|err| {
- CreateError::ValidationError(validation_errors_to_string(err, None))
+ CreateError::ValidationError(validation_errors_to_string(
+ err, None,
+ ))
})?;
if !version_create_data.status.can_be_requested() {
return Err(CreateError::InvalidInput(
- "Status specified cannot be requested".to_string(),
+ CreationInvalidInput::CannotRequestStatus(
+ version_create_data.status,
+ ),
));
}
- let project_id: models::DBProjectId = version_create_data.project_id.unwrap().into();
+ let project_id: models::DBProjectId =
+ version_create_data.project_id.unwrap().into();
// Ensure that the project this version is being added to exists
- if models::DBProject::get_id(project_id, &mut **transaction, redis)
- .await?
- .is_none()
+ if models::DBProject::get_id(
+ project_id,
+ &mut **transaction,
+ redis,
+ )
+ .await?
+ .is_none()
{
return Err(CreateError::InvalidInput(
- "An invalid project id was supplied".to_string(),
+ CreationInvalidInput::InvalidProjectId,
));
}
// Check that the user creating this version is a team member
// of the project the version is being added to.
- let team_member = models::DBTeamMember::get_from_user_id_project(
- project_id,
- user.id.into(),
- false,
- &mut **transaction,
- )
- .await?;
+ let team_member =
+ models::DBTeamMember::get_from_user_id_project(
+ project_id,
+ user.id.into(),
+ false,
+ &mut **transaction,
+ )
+ .await?;
// Get organization attached, if exists, and the member project permissions
- let organization = models::DBOrganization::get_associated_organization_project_id(
- project_id,
- &mut **transaction,
- )
- .await?;
-
- let organization_team_member = if let Some(organization) = &organization {
- models::DBTeamMember::get_from_user_id(
- organization.team_id,
- user.id.into(),
+ let organization =
+ DBOrganization::get_associated_organization_project_id(
+ project_id,
&mut **transaction,
)
- .await?
- } else {
- None
- };
+ .await?;
+
+ let organization_team_member =
+ if let Some(organization) = &organization {
+ models::DBTeamMember::get_from_user_id(
+ organization.team_id,
+ user.id.into(),
+ &mut **transaction,
+ )
+ .await?
+ } else {
+ None
+ };
let permissions = ProjectPermissions::get_permissions_by_role(
&user.role,
@@ -261,15 +280,19 @@ async fn version_create_inner(
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::UPLOAD_VERSION) {
- return Err(CreateError::CustomAuthenticationError(
- "You don't have permission to upload this version!".to_string(),
+ return Err(CreateError::CreationAuthenticationError(
+ CreationAuthenticationError::UploadVersion,
));
}
- let version_id: VersionId = models::generate_version_id(transaction).await?.into();
+ let version_id: VersionId =
+ models::generate_version_id(transaction).await?.into();
- let all_loaders =
- models::loader_fields::Loader::list(&mut **transaction, redis).await?;
+ let all_loaders = models::loader_fields::Loader::list(
+ &mut **transaction,
+ redis,
+ )
+ .await?;
let loaders = version_create_data
.loaders
.iter()
@@ -278,20 +301,28 @@ async fn version_create_inner(
.iter()
.find(|y| y.loader == x.0)
.cloned()
- .ok_or_else(|| CreateError::InvalidLoader(x.0.clone()))
+ .ok_or_else(|| {
+ CreateError::InvalidLoader(x.0.clone())
+ })
})
.collect::, _>>()?;
selected_loaders = Some(loaders.clone());
- let loader_ids: Vec = loaders.iter().map(|y| y.id).collect_vec();
+ let loader_ids: Vec =
+ loaders.iter().map(|y| y.id).collect_vec();
- let loader_fields =
- LoaderField::get_fields(&loader_ids, &mut **transaction, redis).await?;
- let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields(
- &loader_fields,
+ let loader_fields = LoaderField::get_fields(
+ &loader_ids,
&mut **transaction,
redis,
)
.await?;
+ let mut loader_field_enum_values =
+ LoaderFieldEnumValue::list_many_loader_fields(
+ &loader_fields,
+ &mut **transaction,
+ redis,
+ )
+ .await?;
let version_fields = try_create_version_fields(
version_id,
&version_create_data.fields,
@@ -302,7 +333,7 @@ async fn version_create_inner(
let dependencies = version_create_data
.dependencies
.iter()
- .map(|d| models::version_item::DependencyBuilder {
+ .map(|d| DependencyBuilder {
version_id: d.version_id.map(|x| x.into()),
project_id: d.project_id.map(|x| x.into()),
dependency_type: d.dependency_type.to_string(),
@@ -316,12 +347,17 @@ async fn version_create_inner(
author_id: user.id.into(),
name: version_create_data.version_title.clone(),
version_number: version_create_data.version_number.clone(),
- changelog: version_create_data.version_body.clone().unwrap_or_default(),
+ changelog: version_create_data
+ .version_body
+ .clone()
+ .unwrap_or_default(),
files: Vec::new(),
dependencies,
loaders: loader_ids,
version_fields,
- version_type: version_create_data.release_channel.to_string(),
+ version_type: version_create_data
+ .release_channel
+ .to_string(),
featured: version_create_data.featured,
status: version_create_data.status,
requested_status: None,
@@ -332,21 +368,29 @@ async fn version_create_inner(
}
let version = version_builder.as_mut().ok_or_else(|| {
- CreateError::InvalidInput(String::from("`data` field must come before file fields"))
+ CreateError::InvalidInput(
+ CreationInvalidInput::DataFieldOutOfOrder,
+ )
})?;
let loaders = selected_loaders.as_ref().ok_or_else(|| {
- CreateError::InvalidInput(String::from("`data` field must come before file fields"))
+ CreateError::InvalidInput(
+ CreationInvalidInput::DataFieldOutOfOrder,
+ )
})?;
let loaders = loaders
.iter()
.map(|x| Loader(x.loader.clone()))
.collect::>();
- let version_data = initial_version_data
- .clone()
- .ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?;
+ let version_data =
+ initial_version_data.clone().ok_or_else(|| {
+ CreateError::InvalidInput(
+ CreationInvalidInput::MissingDataField,
+ )
+ })?;
- let existing_file_names = version.files.iter().map(|x| x.filename.clone()).collect();
+ let existing_file_names =
+ version.files.iter().map(|x| x.filename.clone()).collect();
upload_file(
&mut field,
@@ -384,15 +428,15 @@ async fn version_create_inner(
}
let version_data = initial_version_data.ok_or_else(|| {
- CreateError::InvalidInput("`data` field is required".to_string())
+ CreateError::InvalidInput(CreationInvalidInput::MissingDataField)
})?;
let builder = version_builder.ok_or_else(|| {
- CreateError::InvalidInput("`data` field is required".to_string())
+ CreateError::InvalidInput(CreationInvalidInput::MissingDataField)
})?;
if builder.files.is_empty() {
return Err(CreateError::InvalidInput(
- "Versions must have at least one file uploaded to them".to_string(),
+ CreationInvalidInput::MissingAnyFiles,
));
}
@@ -403,7 +447,7 @@ async fn version_create_inner(
SELECT follower_id FROM mod_follows
WHERE mod_id = $1
",
- builder.project_id as crate::database::models::ids::DBProjectId
+ builder.project_id as models::ids::DBProjectId
)
.fetch(&mut **transaction)
.map_ok(|m| models::ids::DBUserId(m.follower_id))
@@ -488,9 +532,11 @@ async fn version_create_inner(
if !matches!(image.context, ImageContext::Report { .. })
|| image.context.inner_id().is_some()
{
- return Err(CreateError::InvalidInput(format!(
- "Image {image_id} is not unused and in the 'version' context"
- )));
+ return Err(CreateError::InvalidInput(
+ CreationInvalidInput::ImproperContextImage(
+ image_id, "report",
+ ),
+ ));
}
sqlx::query!(
@@ -507,9 +553,9 @@ async fn version_create_inner(
image_item::DBImage::clear_cache(image.id.into(), redis).await?;
} else {
- return Err(CreateError::InvalidInput(format!(
- "Image {image_id} does not exist"
- )));
+ return Err(CreateError::InvalidInput(
+ CreationInvalidInput::NonexistentImage(image_id),
+ ));
}
}
@@ -538,7 +584,7 @@ pub async fn upload_file_to_version(
client: Data,
redis: Data,
file_host: Data>,
- session_queue: web::Data,
+ session_queue: Data,
) -> Result {
let mut transaction = client.begin().await?;
let mut uploaded_files = Vec::new();
@@ -608,7 +654,7 @@ async fn upload_file_to_version_inner(
let Some(version) = result else {
return Err(CreateError::InvalidInput(
- "An invalid version id was supplied".to_string(),
+ CreationInvalidInput::InvalidVersionId,
));
};
@@ -635,7 +681,7 @@ async fn upload_file_to_version_inner(
.is_none()
{
return Err(CreateError::InvalidInput(
- "An invalid project id was supplied".to_string(),
+ CreationInvalidInput::InvalidProjectId,
));
}
@@ -675,9 +721,8 @@ async fn upload_file_to_version_inner(
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::UPLOAD_VERSION) {
- return Err(CreateError::CustomAuthenticationError(
- "You don't have permission to upload files to this version!"
- .to_string(),
+ return Err(CreateError::CreationAuthenticationError(
+ CreationAuthenticationError::UploadFilesToVersion,
));
}
}
@@ -695,9 +740,7 @@ async fn upload_file_to_version_inner(
let content_disposition =
field.content_disposition().unwrap().clone();
let name = content_disposition.get_name().ok_or_else(|| {
- CreateError::MissingValueError(
- "Missing content name".to_string(),
- )
+ CreateError::MissingValueError(MissingValuePart::ContentName)
})?;
if name == "data" {
@@ -712,9 +755,9 @@ async fn upload_file_to_version_inner(
}
let file_data = initial_file_data.as_ref().ok_or_else(|| {
- CreateError::InvalidInput(String::from(
- "`data` field must come before file fields",
- ))
+ CreateError::InvalidInput(
+ CreationInvalidInput::DataFieldOutOfOrder,
+ )
})?;
let loaders = selected_loaders
@@ -770,7 +813,7 @@ async fn upload_file_to_version_inner(
if file_builders.is_empty() {
return Err(CreateError::InvalidInput(
- "At least one file must be specified".to_string(),
+ CreationInvalidInput::NoFilesSpecified,
));
} else {
for file in file_builders {
@@ -811,14 +854,13 @@ pub async fn upload_file(
if other_file_names.contains(&format!("{file_name}.{file_extension}")) {
return Err(CreateError::InvalidInput(
- "Duplicate files are not allowed to be uploaded to Modrinth!"
- .to_string(),
+ CreationInvalidInput::DuplicateFiles,
));
}
if file_name.contains('/') {
return Err(CreateError::InvalidInput(
- "File names must not contain slashes!".to_string(),
+ CreationInvalidInput::FileNameHasSlashes,
));
}
@@ -828,9 +870,11 @@ pub async fn upload_file(
})?;
let data = read_from_field(
- field, 500 * (1 << 20),
- "Project file exceeds the maximum of 500MiB. Contact a moderator or admin to request permission to upload larger files."
- ).await?;
+ field,
+ 500 * (1 << 20),
+ CreationInvalidInput::ProjectFileTooLarge("500 MiB"),
+ )
+ .await?;
let hash = sha1::Sha1::digest(&data).encode_hex::();
let exists = sqlx::query!(
@@ -851,8 +895,7 @@ pub async fn upload_file(
if exists {
return Err(CreateError::InvalidInput(
- "Duplicate files are not allowed to be uploaded to Modrinth!"
- .to_string(),
+ CreationInvalidInput::DuplicateFiles,
));
}
@@ -968,15 +1011,16 @@ pub async fn upload_file(
.any(|y| y.hash == sha1_bytes || y.hash == sha512_bytes)
}) {
return Err(CreateError::InvalidInput(
- "Duplicate files are not allowed to be uploaded to Modrinth!"
- .to_string(),
+ CreationInvalidInput::DuplicateFiles,
));
}
- if let ValidationResult::Warning(msg) = validation_result
+ if let ValidationResult::Warning(warning) = validation_result
&& primary
{
- return Err(CreateError::InvalidInput(msg.to_string()));
+ return Err(CreateError::InvalidInput(
+ CreationInvalidInput::FileValidation(warning),
+ ));
}
let url = format!("{cdn_url}/{file_path_encode}");
@@ -1032,13 +1076,13 @@ pub fn get_name_ext(
content_disposition: &actix_web::http::header::ContentDisposition,
) -> Result<(&str, &str), CreateError> {
let file_name = content_disposition.get_filename().ok_or_else(|| {
- CreateError::MissingValueError("Missing content file name".to_string())
+ CreateError::MissingValueError(MissingValuePart::ContentFileName)
})?;
let file_extension = if let Some(last_period) = file_name.rfind('.') {
file_name.get((last_period + 1)..).unwrap_or("")
} else {
return Err(CreateError::MissingValueError(
- "Missing content file extension".to_string(),
+ MissingValuePart::ContentFileExtension,
));
};
Ok((file_name, file_extension))
@@ -1066,30 +1110,32 @@ pub fn try_create_version_fields(
.iter()
.find(|lf| &lf.field == key)
.ok_or_else(|| {
- CreateError::InvalidInput(format!(
- "Loader field '{key}' does not exist for any loaders supplied,"
- ))
+ CreateError::InvalidInput(
+ CreationInvalidInput::NonexistentLoaderField(
+ key.to_string(),
+ ),
+ )
})?;
remaining_mandatory_loader_fields.remove(&loader_field.field);
let enum_variants = loader_field_enum_values
.remove(&loader_field.id)
.unwrap_or_default();
- let vf: VersionField = VersionField::check_parse(
+ let vf = VersionField::check_parse(
version_id.into(),
loader_field.clone(),
value.clone(),
enum_variants,
- )
- .map_err(CreateError::InvalidInput)?;
+ )?;
version_fields.push(vf);
}
if !remaining_mandatory_loader_fields.is_empty() {
- return Err(CreateError::InvalidInput(format!(
- "Missing mandatory loader fields: {}",
- remaining_mandatory_loader_fields.iter().join(", ")
- )));
+ return Err(CreateError::InvalidInput(
+ CreationInvalidInput::MissingLoaderFields(
+ remaining_mandatory_loader_fields.iter().join(", "),
+ ),
+ ));
}
Ok(version_fields)
}
diff --git a/apps/labrinth/src/routes/v3/version_file.rs b/apps/labrinth/src/routes/v3/version_file.rs
index 29a7fedda8..3f17912380 100644
--- a/apps/labrinth/src/routes/v3/version_file.rs
+++ b/apps/labrinth/src/routes/v3/version_file.rs
@@ -1,4 +1,3 @@
-use super::ApiError;
use crate::auth::checks::{filter_visible_versions, is_visible_version};
use crate::auth::{filter_visible_projects, get_user_from_headers};
use crate::database::ReadOnlyPgPool;
@@ -8,6 +7,7 @@ use crate::models::pats::Scopes;
use crate::models::projects::VersionType;
use crate::models::teams::ProjectPermissions;
use crate::queue::session::AuthQueue;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
use crate::{database, models};
use actix_web::{HttpRequest, HttpResponse, web};
use dashmap::DashMap;
@@ -610,9 +610,8 @@ pub async fn delete_file(
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::DELETE_VERSION) {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to delete this file!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::DeleteFile,
));
}
}
diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs
index cdca240744..a3a53829a5 100644
--- a/apps/labrinth/src/routes/v3/versions.rs
+++ b/apps/labrinth/src/routes/v3/versions.rs
@@ -1,6 +1,5 @@
use std::collections::HashMap;
-use super::ApiError;
use crate::auth::checks::{
filter_visible_versions, is_visible_project, is_visible_version,
};
@@ -24,6 +23,7 @@ use crate::models::projects::{
use crate::models::projects::{Loader, skip_nulls};
use crate::models::teams::ProjectPermissions;
use crate::queue::session::AuthQueue;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
use crate::search::SearchConfig;
use crate::search::indexing::remove_documents;
use crate::util::img;
@@ -130,11 +130,10 @@ pub async fn versions_get(
redis: web::Data,
session_queue: web::Data,
) -> Result {
- let version_ids =
- serde_json::from_str::>(&ids.ids)?
- .into_iter()
- .map(|x| x.into())
- .collect::>();
+ let version_ids = serde_json::from_str::>(&ids.ids)?
+ .into_iter()
+ .map(|x| x.into())
+ .collect::>();
let versions_data =
database::models::DBVersion::get_many(&version_ids, &**pool, &redis)
.await?;
@@ -159,7 +158,7 @@ pub async fn versions_get(
pub async fn version_get(
req: HttpRequest,
- info: web::Path<(models::ids::VersionId,)>,
+ info: web::Path<(VersionId,)>,
pool: web::Data,
redis: web::Data,
session_queue: web::Data,
@@ -170,7 +169,7 @@ pub async fn version_get(
pub async fn version_get_helper(
req: HttpRequest,
- id: models::ids::VersionId,
+ id: VersionId,
pool: web::Data,
redis: web::Data,
session_queue: web::Data,
@@ -214,7 +213,7 @@ pub struct EditVersion {
pub version_number: Option,
#[validate(length(max = 65536))]
pub changelog: Option,
- pub version_type: Option,
+ pub version_type: Option,
#[validate(
length(min = 0, max = 4096),
custom(function = "crate::util::validate::validate_deps")
@@ -331,9 +330,8 @@ pub async fn version_edit_helper(
if let Some(perms) = permissions {
if !perms.contains(ProjectPermissions::UPLOAD_VERSION) {
- return Err(ApiError::CustomAuthentication(
- "You do not have the permissions to edit this version!"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditVersion,
));
}
@@ -393,13 +391,13 @@ pub async fn version_edit_helper(
let builders = dependencies
.iter()
- .map(|x| database::models::version_item::DependencyBuilder {
+ .map(|x| DependencyBuilder {
project_id: x.project_id.map(|x| x.into()),
version_id: x.version_id.map(|x| x.into()),
file_name: x.file_name.clone(),
dependency_type: x.dependency_type.to_string(),
})
- .collect::>();
+ .collect::>();
DependencyBuilder::insert_many(
builders,
@@ -477,13 +475,12 @@ pub async fn version_edit_helper(
let enum_variants = loader_field_enum_values
.remove(&loader_field.id)
.unwrap_or_default();
- let vf: VersionField = VersionField::check_parse(
+ let vf = VersionField::check_parse(
version_id,
loader_field.clone(),
vf_value.clone(),
enum_variants,
- )
- .map_err(ApiError::InvalidInput)?;
+ )?;
version_fields.push(vf);
}
VersionField::insert_many(version_fields, &mut transaction)
@@ -502,19 +499,18 @@ pub async fn version_edit_helper(
let mut loader_versions = Vec::new();
for loader in loaders {
- let loader_id =
- database::models::loader_fields::Loader::get_id(
- &loader.0,
- &mut *transaction,
- &redis,
+ let loader_id = loader_fields::Loader::get_id(
+ &loader.0,
+ &mut *transaction,
+ &redis,
+ )
+ .await?
+ .ok_or_else(|| {
+ ApiError::InvalidInput(
+ "No database entry for loader provided."
+ .to_string(),
)
- .await?
- .ok_or_else(|| {
- ApiError::InvalidInput(
- "No database entry for loader provided."
- .to_string(),
- )
- })?;
+ })?;
loader_versions.push(DBLoaderVersion {
loader_id,
version_id,
@@ -523,7 +519,7 @@ pub async fn version_edit_helper(
DBLoaderVersion::insert_many(loader_versions, &mut transaction)
.await?;
- crate::database::models::DBProject::clear_cache(
+ database::models::DBProject::clear_cache(
version_item.inner.project_id,
None,
None,
@@ -562,8 +558,8 @@ pub async fn version_edit_helper(
if let Some(downloads) = &new_version.downloads {
if !user.role.is_mod() {
- return Err(ApiError::CustomAuthentication(
- "You don't have permission to set the downloads of this mod".to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::SetModDownloads,
));
}
@@ -692,8 +688,8 @@ pub async fn version_edit_helper(
.await?;
Ok(HttpResponse::NoContent().body(""))
} else {
- Err(ApiError::CustomAuthentication(
- "You do not have permission to edit this version!".to_string(),
+ Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::EditVersion,
))
}
} else {
@@ -810,7 +806,7 @@ pub async fn version_list(
// TODO: This is a bandaid fix for detecting auto-featured versions.
// In the future, not all versions will have 'game_versions' fields, so this will need to be changed.
let (loaders, game_versions) = futures::future::try_join(
- database::models::loader_fields::Loader::list(&**pool, &redis),
+ loader_fields::Loader::list(&**pool, &redis),
database::models::legacy_loader_fields::MinecraftGameVersion::list(
None,
Some(true),
@@ -930,9 +926,8 @@ pub async fn version_delete(
.unwrap_or_default();
if !permissions.contains(ProjectPermissions::DELETE_VERSION) {
- return Err(ApiError::CustomAuthentication(
- "You do not have permission to delete versions in this team"
- .to_string(),
+ return Err(ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::DeleteVersionsInTeam,
));
}
}
diff --git a/apps/labrinth/src/search/indexing/local_import.rs b/apps/labrinth/src/search/indexing/local_import.rs
index 4fca8ea337..5c03d93ded 100644
--- a/apps/labrinth/src/search/indexing/local_import.rs
+++ b/apps/labrinth/src/search/indexing/local_import.rs
@@ -50,7 +50,7 @@ pub async fn index_local(
",
&*crate::models::projects::ProjectStatus::iterator()
.filter(|x| x.is_searchable())
- .map(|x| x.to_string())
+ .map(|x| x.as_str().to_string())
.collect::>(),
)
.fetch(pool)
diff --git a/apps/labrinth/src/search/indexing/mod.rs b/apps/labrinth/src/search/indexing/mod.rs
index 1ecb70ad79..93d1e09afc 100644
--- a/apps/labrinth/src/search/indexing/mod.rs
+++ b/apps/labrinth/src/search/indexing/mod.rs
@@ -5,6 +5,7 @@ use std::time::Duration;
use crate::database::redis::RedisPool;
use crate::search::{SearchConfig, UploadSearchProject};
+use ariadne::i18n_enum;
use ariadne::ids::base62_impl::to_base62;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
@@ -32,11 +33,22 @@ pub enum IndexingError {
Task,
}
+i18n_enum!(
+ IndexingError,
+ root_key: "labrinth.error.indexing",
+ Indexing(..) => "meilisearch",
+ Serde(cause) => "serialization",
+ Sqlx(cause) => "sqlx",
+ Database(cause) => "database",
+ Env(..) => "environment",
+ Task! => "task",
+);
+
// The chunk size for adding projects to the indexing database. If the request size
// is too large (>10MiB) then the request fails with an error. This chunk size
// assumes a max average size of 4KiB per project to avoid this cap.
const MEILISEARCH_CHUNK_SIZE: usize = 10000000;
-const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
+const TIMEOUT: Duration = Duration::from_secs(60);
pub async fn remove_documents(
ids: &[crate::models::ids::VersionId],
@@ -244,11 +256,7 @@ async fn add_to_index(
index
.add_or_replace(chunk, Some("version_id"))
.await?
- .wait_for_completion(
- client,
- None,
- Some(std::time::Duration::from_secs(3600)),
- )
+ .wait_for_completion(client, None, Some(Duration::from_secs(3600)))
.await?;
info!("Added chunk of {} projects to index", chunk.len());
}
diff --git a/apps/labrinth/src/search/mod.rs b/apps/labrinth/src/search/mod.rs
index 663e1a83fa..594e12e47e 100644
--- a/apps/labrinth/src/search/mod.rs
+++ b/apps/labrinth/src/search/mod.rs
@@ -1,7 +1,8 @@
-use crate::models::error::ApiError;
+use crate::models::error::AsApiError;
use crate::models::projects::SearchRequest;
use actix_web::HttpResponse;
use actix_web::http::StatusCode;
+use ariadne::i18n_enum;
use chrono::{DateTime, Utc};
use itertools::Itertools;
use meilisearch_sdk::client::Client;
@@ -30,6 +31,17 @@ pub enum SearchError {
InvalidIndex(String),
}
+i18n_enum!(
+ SearchError,
+ root_key: "labrinth.error.search",
+ MeiliSearch(cause) => "meilisearch_error",
+ Serde(cause) => "invalid_input.serialization",
+ IntParsing(cause) => "invalid_input.int_parsing",
+ FormatError(cause) => "invalid_input.formatting",
+ Env(..) => "environment_error",
+ InvalidIndex(index) => "invalid_input.index",
+);
+
impl actix_web::ResponseError for SearchError {
fn status_code(&self) -> StatusCode {
match self {
@@ -43,17 +55,7 @@ impl actix_web::ResponseError for SearchError {
}
fn error_response(&self) -> HttpResponse {
- HttpResponse::build(self.status_code()).json(ApiError {
- error: match self {
- SearchError::Env(..) => "environment_error",
- SearchError::MeiliSearch(..) => "meilisearch_error",
- SearchError::Serde(..) => "invalid_input",
- SearchError::IntParsing(..) => "invalid_input",
- SearchError::InvalidIndex(..) => "invalid_input",
- SearchError::FormatError(..) => "invalid_input",
- },
- description: self.to_string(),
- })
+ HttpResponse::build(self.status_code()).json(self.as_api_error())
}
}
@@ -126,10 +128,10 @@ pub struct UploadSearchProject {
// Hidden fields to get the Project model out of the search results.
pub loaders: Vec, // Search uses loaders as categories- this is purely for the Project model.
- pub project_loader_fields: HashMap>, // Aggregation of loader_fields from all versions of the project, allowing for reconstruction of the Project model.
+ pub project_loader_fields: HashMap>, // Aggregation of loader_fields from all versions of the project, allowing for reconstruction of the Project model.
#[serde(flatten)]
- pub loader_fields: HashMap>,
+ pub loader_fields: HashMap>,
}
#[derive(Serialize, Deserialize, Debug)]
@@ -165,10 +167,10 @@ pub struct ResultSearchProject {
// Hidden fields to get the Project model out of the search results.
pub loaders: Vec, // Search uses loaders as categories- this is purely for the Project model.
- pub project_loader_fields: HashMap>, // Aggregation of loader_fields from all versions of the project, allowing for reconstruction of the Project model.
+ pub project_loader_fields: HashMap>, // Aggregation of loader_fields from all versions of the project, allowing for reconstruction of the Project model.
#[serde(flatten)]
- pub loader_fields: HashMap>,
+ pub loader_fields: HashMap>,
}
pub fn get_sort_index(
diff --git a/apps/labrinth/src/util/archon.rs b/apps/labrinth/src/util/archon.rs
index 471faa7b2c..dce62e67fd 100644
--- a/apps/labrinth/src/util/archon.rs
+++ b/apps/labrinth/src/util/archon.rs
@@ -2,7 +2,7 @@ use reqwest::header::HeaderName;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
const X_MASTER_KEY: HeaderName = HeaderName::from_static("x-master-key");
diff --git a/apps/labrinth/src/util/avalara1099.rs b/apps/labrinth/src/util/avalara1099.rs
index b861b8b2da..9fc8d9fab2 100644
--- a/apps/labrinth/src/util/avalara1099.rs
+++ b/apps/labrinth/src/util/avalara1099.rs
@@ -1,5 +1,5 @@
use crate::database::models::{DBUserId, users_compliance::FormType};
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use ariadne::ids::base62_impl::to_base62;
use chrono::{Datelike, NaiveDateTime};
use serde::{Deserialize, Serialize};
diff --git a/apps/labrinth/src/util/captcha.rs b/apps/labrinth/src/util/captcha.rs
index e59d5ae5d7..bdd32f1a92 100644
--- a/apps/labrinth/src/util/captcha.rs
+++ b/apps/labrinth/src/util/captcha.rs
@@ -1,4 +1,4 @@
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use crate::util::env::parse_var;
use actix_web::HttpRequest;
use serde::Deserialize;
diff --git a/apps/labrinth/src/util/img.rs b/apps/labrinth/src/util/img.rs
index 9287c88732..b0ae40cd47 100644
--- a/apps/labrinth/src/util/img.rs
+++ b/apps/labrinth/src/util/img.rs
@@ -3,7 +3,7 @@ use crate::database::models::image_item;
use crate::database::redis::RedisPool;
use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::images::ImageContext;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use color_thief::ColorFormat;
use hex::ToHex;
use image::imageops::FilterType;
diff --git a/apps/labrinth/src/util/ratelimit.rs b/apps/labrinth/src/util/ratelimit.rs
index c64c801fe0..7c9add3c34 100644
--- a/apps/labrinth/src/util/ratelimit.rs
+++ b/apps/labrinth/src/util/ratelimit.rs
@@ -1,5 +1,5 @@
use crate::database::redis::RedisPool;
-use crate::routes::ApiError;
+use crate::routes::error::{ApiError, SpecificAuthenticationError};
use crate::util::env::parse_var;
use actix_web::{
Error, ResponseError,
@@ -225,8 +225,8 @@ pub async fn rate_limit_middleware(
Ok(req.into_response(response.map_into_right_body()))
}
} else {
- let response = ApiError::CustomAuthentication(
- "Unable to obtain user IP address!".to_string(),
+ let response = ApiError::SpecificAuthentication(
+ SpecificAuthenticationError::UnknownUserIp,
)
.error_response();
diff --git a/apps/labrinth/src/util/routes.rs b/apps/labrinth/src/util/routes.rs
index c963937213..00011bb8e6 100644
--- a/apps/labrinth/src/util/routes.rs
+++ b/apps/labrinth/src/util/routes.rs
@@ -1,5 +1,5 @@
-use crate::routes::ApiError;
-use crate::routes::v3::project_creation::CreateError;
+use crate::routes::error::ApiError;
+use crate::routes::v3::create_error::{CreateError, CreationInvalidInput};
use crate::util::validate::validation_errors_to_string;
use actix_multipart::Field;
use actix_web::web::Payload;
@@ -53,14 +53,14 @@ where
pub async fn read_from_field(
field: &mut Field,
cap: usize,
- err_msg: &'static str,
+ error: CreationInvalidInput,
) -> Result {
let mut bytes = BytesMut::new();
while let Some(chunk) = field.next().await {
let chunk = chunk?;
if bytes.len().saturating_add(chunk.len()) > cap {
- return Err(CreateError::InvalidInput(String::from(err_msg)));
+ return Err(CreateError::InvalidInput(error));
}
bytes.extend_from_slice(&chunk);
diff --git a/apps/labrinth/src/util/webhook.rs b/apps/labrinth/src/util/webhook.rs
index 7ae4e8a301..b720546231 100644
--- a/apps/labrinth/src/util/webhook.rs
+++ b/apps/labrinth/src/util/webhook.rs
@@ -1,7 +1,7 @@
use crate::database::models::legacy_loader_fields::MinecraftGameVersion;
use crate::database::redis::RedisPool;
use crate::models::ids::ProjectId;
-use crate::routes::ApiError;
+use crate::routes::error::ApiError;
use ariadne::ids::base62_impl::to_base62;
use chrono::{DateTime, Utc};
use serde::Serialize;
@@ -113,7 +113,7 @@ async fn get_webhook_metadata(
.clone()
.into_iter()
.find_map(|vf| {
- MinecraftGameVersion::try_from_version_field(&vf).ok()
+ MinecraftGameVersion::try_from_version_field(vf).ok()
})
.unwrap_or_default();
@@ -240,9 +240,7 @@ pub async fn send_slack_payout_source_alert_webhook(
}))
.send()
.await
- .map_err(|_| {
- ApiError::Slack("Error while sending projects webhook".to_string())
- })?;
+ .map_err(|_| ApiError::Slack)?;
Ok(())
}
@@ -356,11 +354,7 @@ pub async fn send_slack_project_webhook(
}))
.send()
.await
- .map_err(|_| {
- ApiError::Slack(
- "Error while sending projects webhook".to_string(),
- )
- })?;
+ .map_err(|_| ApiError::Slack)?;
}
Ok(())
diff --git a/apps/labrinth/src/validate/datapack.rs b/apps/labrinth/src/validate/datapack.rs
index f152486d4f..23a10e9745 100644
--- a/apps/labrinth/src/validate/datapack.rs
+++ b/apps/labrinth/src/validate/datapack.rs
@@ -1,6 +1,6 @@
use crate::validate::{
MaybeProtectedZipFile, PLAUSIBLE_PACK_REGEX, SupportedGameVersions,
- ValidationError, ValidationResult,
+ ValidationError, ValidationResult, ValidationWarning,
};
use chrono::DateTime;
@@ -37,7 +37,7 @@ impl super::Validator for DataPackValidator {
Ok(ValidationResult::Pass)
} else {
Ok(ValidationResult::Warning(
- "No pack.mcmeta present for datapack file. Tip: Make sure pack.mcmeta is in the root directory of your datapack!",
+ ValidationWarning::MissingDatapackPackMcmeta,
))
}
}
diff --git a/apps/labrinth/src/validate/fabric.rs b/apps/labrinth/src/validate/fabric.rs
index 073087b791..be92ef8e3d 100644
--- a/apps/labrinth/src/validate/fabric.rs
+++ b/apps/labrinth/src/validate/fabric.rs
@@ -1,5 +1,6 @@
use crate::validate::{
- SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
+ SupportedGameVersions, ValidationError, ValidationResult,
+ ValidationWarning, filter_out_packs,
};
use std::io::Cursor;
use zip::ZipArchive;
@@ -25,7 +26,7 @@ impl super::Validator for FabricValidator {
) -> Result {
if archive.by_name("fabric.mod.json").is_err() {
return Ok(ValidationResult::Warning(
- "No fabric.mod.json present for Fabric file.",
+ ValidationWarning::MissingFabricModJson,
));
}
diff --git a/apps/labrinth/src/validate/forge.rs b/apps/labrinth/src/validate/forge.rs
index da8b49a8b4..99e1e071f6 100644
--- a/apps/labrinth/src/validate/forge.rs
+++ b/apps/labrinth/src/validate/forge.rs
@@ -1,5 +1,6 @@
use crate::validate::{
- SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
+ SupportedGameVersions, ValidationError, ValidationResult,
+ ValidationWarning, filter_out_packs,
};
use chrono::DateTime;
use std::io::Cursor;
@@ -32,7 +33,7 @@ impl super::Validator for ForgeValidator {
&& !archive.file_names().any(|x| x.ends_with(".class"))
{
return Ok(ValidationResult::Warning(
- "No mods.toml or valid class files present for Forge file.",
+ ValidationWarning::MissingModsToml,
));
}
@@ -70,7 +71,7 @@ impl super::Validator for LegacyForgeValidator {
&& !archive.file_names().any(|x| x.ends_with(".class"))
{
return Ok(ValidationResult::Warning(
- "Forge mod file does not contain mcmod.info or valid class files!",
+ ValidationWarning::MissingMcmodInfo,
));
};
diff --git a/apps/labrinth/src/validate/liteloader.rs b/apps/labrinth/src/validate/liteloader.rs
index 78029f1f1e..91c8cf8280 100644
--- a/apps/labrinth/src/validate/liteloader.rs
+++ b/apps/labrinth/src/validate/liteloader.rs
@@ -1,5 +1,6 @@
use crate::validate::{
- SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
+ SupportedGameVersions, ValidationError, ValidationResult,
+ ValidationWarning, filter_out_packs,
};
use std::io::Cursor;
use zip::ZipArchive;
@@ -25,7 +26,7 @@ impl super::Validator for LiteLoaderValidator {
) -> Result {
if archive.by_name("litemod.json").is_err() {
return Ok(ValidationResult::Warning(
- "No litemod.json present for LiteLoader file.",
+ ValidationWarning::MissingLitemodJson,
));
}
diff --git a/apps/labrinth/src/validate/mod.rs b/apps/labrinth/src/validate/mod.rs
index 6304b61ddc..c5b825191d 100644
--- a/apps/labrinth/src/validate/mod.rs
+++ b/apps/labrinth/src/validate/mod.rs
@@ -17,8 +17,10 @@ use crate::validate::rift::RiftValidator;
use crate::validate::shader::{
CanvasShaderValidator, CoreShaderValidator, ShaderValidator,
};
+use ariadne::i18n_enum;
use bytes::Bytes;
use chrono::{DateTime, Utc};
+use derive_more::Display;
use std::io::{self, Cursor};
use std::mem;
use std::sync::LazyLock;
@@ -41,19 +43,116 @@ mod shader;
#[derive(Error, Debug)]
pub enum ValidationError {
#[error("Unable to read Zip Archive: {0}")]
- Zip(#[from] zip::result::ZipError),
+ Zip(#[from] ZipError),
#[error("IO Error: {0}")]
- Io(#[from] std::io::Error),
+ Io(#[from] io::Error),
#[error("Error while validating JSON for uploaded file: {0}")]
SerDe(#[from] serde_json::Error),
#[error("Invalid Input: {0}")]
- InvalidInput(std::borrow::Cow<'static, str>),
+ InvalidInput(std::borrow::Cow<'static, str>), // TODO: Use I18nEnum
#[error("Error while managing threads")]
Blocking(#[from] actix_web::error::BlockingError),
#[error("Error while querying database")]
Database(#[from] DatabaseError),
}
+i18n_enum!(
+ ValidationError,
+ root_key: "labrinth.error.file_validation",
+ Zip(cause) => "zip",
+ Io(cause) => "io",
+ SerDe(cause) => "serialization",
+ InvalidInput(cause) => "invalid_input",
+ Blocking(..) => "blocking",
+ Database(..) => "database",
+);
+
+#[derive(Eq, PartialEq, Debug, Display)]
+pub enum ValidationWarning {
+ #[display(
+ "No pack.mcmeta present for datapack file. Tip: Make sure pack.mcmeta is in the root directory of your datapack!"
+ )]
+ MissingDatapackPackMcmeta,
+ #[display("No fabric.mod.json present for Fabric file.")]
+ MissingFabricModJson,
+ #[display("No mods.toml or valid class files present for Forge file.")]
+ MissingModsToml,
+ #[display(
+ "Forge mod file does not contain mcmod.info or valid class files!"
+ )]
+ MissingMcmodInfo,
+ #[display("No litemod.json present for LiteLoader file.")]
+ MissingLitemodJson,
+ #[display("Pack manifest is missing.")]
+ MissingPackManifest,
+ #[display(
+ "No neoforge.mods.toml, mods.toml, or valid class files present for NeoForge file."
+ )]
+ MissingNeoforgeModsToml,
+ #[display("No plugin.yml or paper-plugin.yml present for plugin file.")]
+ MissingBukkitPluginYml,
+ #[display("No plugin.yml or bungee.yml present for plugin file.")]
+ MissingBungeecordPluginYml,
+ #[display("No velocity-plugin.json present for plugin file.")]
+ MissingVelocityPluginJson,
+ #[display(
+ "No sponge_plugins.json or mcmod.info present for Sponge plugin."
+ )]
+ MissingSpongePluginsJson,
+ #[display("No quilt.mod.json present for Quilt file.")]
+ MissingQuiltModJson,
+ #[display(
+ "No pack.mcmeta present for resource pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!"
+ )]
+ MissingResourcePackPackMcmeta,
+ #[display("No pack.txt present for pack file.")]
+ MissingPackTxt,
+ #[display("No riftmod.json present for Rift file.")]
+ MissingRiftmodJson,
+ #[display("No shaders folder present for OptiFine/Iris shader.")]
+ MissingShadersFolder,
+ #[display(
+ "No pack.mcmeta present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!"
+ )]
+ MissingCanvasPackMcmeta,
+ #[display("No pipeline shaders folder present for canvas shaders.")]
+ MissingPipelinesFolder,
+ #[display(
+ "No pack.mcmeta or vanilla shaders folder present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!"
+ )]
+ MissingVanillaShadersFolder,
+ #[display("File extension is invalid for input file")]
+ WrongFileExtension,
+ #[display("Invalid modpack file. You must upload a valid .mrpack file.")]
+ InvalidMrpack,
+}
+
+i18n_enum!(
+ ValidationWarning,
+ root_key: "labrinth.warning.file_validation",
+ MissingDatapackPackMcmeta! => "missing_datapack_pack_mcmeta",
+ MissingFabricModJson! => "missing_fabric_mod_json",
+ MissingModsToml! => "missing_mods_toml",
+ MissingMcmodInfo! => "missing_mcmod_info",
+ MissingLitemodJson! => "missing_litemod_json",
+ MissingPackManifest! => "missing_pack_manifest",
+ MissingNeoforgeModsToml! => "missing_neoforge_mods_toml",
+ MissingBukkitPluginYml! => "missing_bukkit_plugin_yml",
+ MissingBungeecordPluginYml! => "missing_bungeecord_plugin_yml",
+ MissingVelocityPluginJson! => "missing_velocity_plugin_json",
+ MissingSpongePluginsJson! => "missing_sponge_plugins_json",
+ MissingQuiltModJson! => "missing_quilt_mod_json",
+ MissingResourcePackPackMcmeta! => "missing_resource_pack_pack_mcmeta",
+ MissingPackTxt! => "missing_pack_txt",
+ MissingRiftmodJson! => "missing_riftmod_json",
+ MissingShadersFolder! => "missing_shaders_folder",
+ MissingCanvasPackMcmeta! => "missing_canvas_pack_mcmeta",
+ MissingPipelinesFolder! => "missing_pipelines_folder",
+ MissingVanillaShadersFolder! => "missing_vanilla_shaders_folder",
+ WrongFileExtension! => "wrong_file_extension",
+ InvalidMrpack! => "invalid_mrpack",
+);
+
#[derive(Eq, PartialEq, Debug)]
pub enum ValidationResult {
/// File should be marked as primary with pack file data
@@ -64,7 +163,7 @@ pub enum ValidationResult {
/// File should be marked as primary
Pass,
/// File should not be marked primary, the reason for which is inside the String
- Warning(&'static str),
+ Warning(ValidationWarning),
}
impl ValidationResult {
@@ -96,7 +195,7 @@ pub trait Validator: Sync {
fn validate(
&self,
- archive: &mut ZipArchive>,
+ archive: &mut ZipArchive>,
) -> Result {
// By default, any non-protected ZIP archive is valid
let _ = archive;
@@ -172,7 +271,7 @@ static PLAUSIBLE_PACK_REGEX: LazyLock =
/// The return value is whether this file should be marked as primary or not, based on the analysis of the file
#[allow(clippy::too_many_arguments)]
pub async fn validate_file(
- data: bytes::Bytes,
+ data: Bytes,
file_extension: String,
loaders: Vec