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, file_type: Option, @@ -182,7 +281,7 @@ pub async fn validate_file( ) -> Result { let game_versions = version_fields .into_iter() - .find_map(|v| MinecraftGameVersion::try_from_version_field(&v).ok()) + .find_map(|v| MinecraftGameVersion::try_from_version_field(v).ok()) .unwrap_or_default(); let all_game_versions = MinecraftGameVersion::list(None, None, &mut *transaction, redis) @@ -264,9 +363,7 @@ async fn validate_minecraft_file( if visited { if ALWAYS_ALLOWED_EXT.contains(&&*file_extension) { - Ok(ValidationResult::Warning( - "File extension is invalid for input file", - )) + Ok(ValidationResult::Warning(ValidationWarning::WrongFileExtension)) } else { Err(ValidationError::InvalidInput( format!("File extension {file_extension} is invalid for input file").into(), @@ -314,7 +411,7 @@ fn game_version_supported( } pub fn filter_out_packs( - archive: &mut ZipArchive>, + archive: &mut ZipArchive>, ) -> Result { if (archive.by_name("modlist.html").is_ok() && archive.by_name("manifest.json").is_ok()) @@ -325,9 +422,7 @@ pub fn filter_out_packs( .file_names() .any(|x| x.starts_with("override/mods/") && x.ends_with(".jar")) { - return Ok(ValidationResult::Warning( - "Invalid modpack file. You must upload a valid .MRPACK file.", - )); + return Ok(ValidationResult::Warning(ValidationWarning::InvalidMrpack)); } Ok(ValidationResult::Pass) diff --git a/apps/labrinth/src/validate/modpack.rs b/apps/labrinth/src/validate/modpack.rs index 8e66005c3f..d9aaf87021 100644 --- a/apps/labrinth/src/validate/modpack.rs +++ b/apps/labrinth/src/validate/modpack.rs @@ -1,7 +1,7 @@ use crate::models::pack::{PackFileHash, PackFormat}; use crate::util::validate::validation_errors_to_string; use crate::validate::{ - SupportedGameVersions, ValidationError, ValidationResult, + SupportedGameVersions, ValidationError, ValidationResult, ValidationWarning, }; use std::io::{Cursor, Read}; use std::path::Component; @@ -30,7 +30,7 @@ impl super::Validator for ModpackValidator { let pack: PackFormat = { let Ok(mut file) = archive.by_name("modrinth.index.json") else { return Ok(ValidationResult::Warning( - "Pack manifest is missing.", + ValidationWarning::MissingPackManifest, )); }; diff --git a/apps/labrinth/src/validate/neoforge.rs b/apps/labrinth/src/validate/neoforge.rs index b857274565..88fa7c6965 100644 --- a/apps/labrinth/src/validate/neoforge.rs +++ b/apps/labrinth/src/validate/neoforge.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; @@ -29,7 +30,7 @@ impl super::Validator for NeoForgeValidator { && !archive.file_names().any(|x| x.ends_with(".class")) { return Ok(ValidationResult::Warning( - "No neoforge.mods.toml, mods.toml, or valid class files present for NeoForge file.", + ValidationWarning::MissingNeoforgeModsToml, )); } diff --git a/apps/labrinth/src/validate/plugin.rs b/apps/labrinth/src/validate/plugin.rs index 4f637c66f2..39c1ac1cd3 100644 --- a/apps/labrinth/src/validate/plugin.rs +++ b/apps/labrinth/src/validate/plugin.rs @@ -1,5 +1,5 @@ use crate::validate::{ - SupportedGameVersions, ValidationError, ValidationResult, + SupportedGameVersions, ValidationError, ValidationResult, ValidationWarning, }; use std::io::Cursor; use zip::ZipArchive; @@ -28,7 +28,7 @@ impl super::Validator for PluginYmlValidator { .any(|name| name == "plugin.yml" || name == "paper-plugin.yml") { return Ok(ValidationResult::Warning( - "No plugin.yml or paper-plugin.yml present for plugin file.", + ValidationWarning::WrongFileExtension, )); }; @@ -60,7 +60,7 @@ impl super::Validator for BungeeCordValidator { .any(|name| name == "plugin.yml" || name == "bungee.yml") { return Ok(ValidationResult::Warning( - "No plugin.yml or bungee.yml present for plugin file.", + ValidationWarning::MissingBungeecordPluginYml, )); }; @@ -89,7 +89,7 @@ impl super::Validator for VelocityValidator { ) -> Result { if archive.by_name("velocity-plugin.json").is_err() { return Ok(ValidationResult::Warning( - "No velocity-plugin.json present for plugin file.", + ValidationWarning::MissingVelocityPluginJson, )); } @@ -122,7 +122,7 @@ impl super::Validator for SpongeValidator { || name == "META-INF/sponge_plugins.json" }) { return Ok(ValidationResult::Warning( - "No sponge_plugins.json or mcmod.info present for Sponge plugin.", + ValidationWarning::MissingSpongePluginsJson, )); }; diff --git a/apps/labrinth/src/validate/quilt.rs b/apps/labrinth/src/validate/quilt.rs index 08312f5661..854d827bfe 100644 --- a/apps/labrinth/src/validate/quilt.rs +++ b/apps/labrinth/src/validate/quilt.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; @@ -30,7 +31,7 @@ impl super::Validator for QuiltValidator { && archive.by_name("fabric.mod.json").is_err() { return Ok(ValidationResult::Warning( - "No quilt.mod.json present for Quilt file.", + ValidationWarning::MissingQuiltModJson, )); } diff --git a/apps/labrinth/src/validate/resourcepack.rs b/apps/labrinth/src/validate/resourcepack.rs index 1d9d52c352..db2d65ae4f 100644 --- a/apps/labrinth/src/validate/resourcepack.rs +++ b/apps/labrinth/src/validate/resourcepack.rs @@ -1,6 +1,6 @@ use crate::validate::{ MaybeProtectedZipFile, PLAUSIBLE_PACK_REGEX, SupportedGameVersions, - ValidationError, ValidationResult, + ValidationError, ValidationResult, ValidationWarning, }; use chrono::DateTime; use std::io::Cursor; @@ -39,7 +39,7 @@ impl super::Validator for PackValidator { Ok(ValidationResult::Pass) } else { Ok(ValidationResult::Warning( - "No pack.mcmeta present for resourcepack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", + ValidationWarning::MissingResourcePackPackMcmeta, )) } } @@ -70,7 +70,7 @@ impl super::Validator for TexturePackValidator { ) -> Result { if archive.by_name("pack.txt").is_err() { return Ok(ValidationResult::Warning( - "No pack.txt present for pack file.", + ValidationWarning::MissingPackTxt, )); } diff --git a/apps/labrinth/src/validate/rift.rs b/apps/labrinth/src/validate/rift.rs index b4c6c5d9c1..d0b0c4c716 100644 --- a/apps/labrinth/src/validate/rift.rs +++ b/apps/labrinth/src/validate/rift.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 RiftValidator { ) -> Result { if archive.by_name("riftmod.json").is_err() { return Ok(ValidationResult::Warning( - "No riftmod.json present for Rift file.", + ValidationWarning::MissingRiftmodJson, )); } diff --git a/apps/labrinth/src/validate/shader.rs b/apps/labrinth/src/validate/shader.rs index 2ba7d7222d..bdf86607cd 100644 --- a/apps/labrinth/src/validate/shader.rs +++ b/apps/labrinth/src/validate/shader.rs @@ -1,6 +1,6 @@ use crate::validate::{ MaybeProtectedZipFile, PLAUSIBLE_PACK_REGEX, SupportedGameVersions, - ValidationError, ValidationResult, + ValidationError, ValidationResult, ValidationWarning, }; use std::{io::Cursor, sync::LazyLock}; use zip::ZipArchive; @@ -26,7 +26,7 @@ impl super::Validator for ShaderValidator { ) -> Result { if !archive.file_names().any(|x| x.starts_with("shaders/")) { return Ok(ValidationResult::Warning( - "No shaders folder present for OptiFine/Iris shader.", + ValidationWarning::MissingShadersFolder, )); } @@ -55,13 +55,13 @@ impl super::Validator for CanvasShaderValidator { ) -> Result { if archive.by_name("pack.mcmeta").is_err() { return Ok(ValidationResult::Warning( - "No pack.mcmeta present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", + ValidationWarning::MissingCanvasPackMcmeta, )); }; if !archive.file_names().any(|x| x.contains("/pipelines/")) { return Ok(ValidationResult::Warning( - "No pipeline shaders folder present for canvas shaders.", + ValidationWarning::MissingPipelinesFolder, )); } @@ -118,7 +118,7 @@ impl super::Validator for CoreShaderValidator { Ok(ValidationResult::Pass) } else { Ok(ValidationResult::Warning( - "No pack.mcmeta or vanilla shaders folder present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", + ValidationWarning::MissingVanillaShadersFolder, )) } } diff --git a/package.json b/package.json index f9c7d23434..abd44b45f2 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,9 @@ "web:build": "turbo run build --filter=@modrinth/frontend", "web:fix": "turbo run fix --filter=@modrinth/frontend", "web:intl:extract": "pnpm run --filter=@modrinth/frontend intl:extract", - "app:dev": "turbo run dev --filter=@modrinth/app", + "labrinth:intl:extract": "pnpm run --filter=@modrinth/labrinth intl:extract", "docs:dev": "turbo run dev --filter=@modrinth/docs", + "app:dev": "turbo run dev --filter=@modrinth/app", "app:build": "turbo run build --filter=@modrinth/app", "app:fix": "turbo run fix --filter=@modrinth/app", "app:intl:extract": "pnpm run --filter=@modrinth/app-frontend intl:extract", @@ -25,7 +26,7 @@ "fix:frontend": "turbo run fix --continue --parallel --filter='!@modrinth/labrinth' --filter='!@modrinth/app' --filter='!@modrinth/app-lib' --filter='!@modrinth/daedalus' --filter='!@modrinth/daedalus_client' --filter='!@modrinth/app-playground'", "fix:ancillary": "prettier --write .github *.*", "ci": "turbo run lint test --continue", - "intl:extract": "pnpm ui:intl:extract && pnpm web:intl:extract && pnpm app:intl:extract && pnpm moderation:intl:extract" + "intl:extract": "pnpm ui:intl:extract && pnpm web:intl:extract && pnpm labrinth:intl:extract && pnpm app:intl:extract && pnpm moderation:intl:extract" }, "devDependencies": { "@modrinth/tooling-config": "workspace:*", diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 659a9d9ccd..fb4e8e7ef9 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true [dependencies] bytes = { workspace = true, features = ["serde"] } -serde = { workspace = true, features = ["derive"] } +serde.workspace = true serde_json.workspace = true serde_ini.workspace = true serde_with.workspace = true diff --git a/packages/ariadne/Cargo.toml b/packages/ariadne/Cargo.toml index 66c7e120cf..21225c543e 100644 --- a/packages/ariadne/Cargo.toml +++ b/packages/ariadne/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true [dependencies] -serde = { workspace = true, features = ["derive"] } +serde.workspace = true serde_json.workspace = true thiserror.workspace = true uuid = { workspace = true, features = ["v4", "fast-rng", "serde"] } diff --git a/packages/ariadne/package.json b/packages/ariadne/package.json new file mode 100644 index 0000000000..aff7009138 --- /dev/null +++ b/packages/ariadne/package.json @@ -0,0 +1,11 @@ +{ + "name": "@modrinth/ariadne", + "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/packages/ariadne/src/i18n.rs b/packages/ariadne/src/i18n.rs new file mode 100644 index 0000000000..e2d260841c --- /dev/null +++ b/packages/ariadne/src/i18n.rs @@ -0,0 +1,285 @@ +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::collections::BTreeMap; + +pub trait I18nEnum { + const ROOT_TRANSLATION_ID: &'static str; + + fn translation_id(&self) -> &'static str; + + fn full_translation_id(&self) -> &'static str; + + fn translation_data(&self) -> TranslationData; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum TranslationData { + Literal(String), + Translatable { + key: Cow<'static, str>, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + values: BTreeMap, TranslationData>, + }, +} + +// The extractor in ariadne_extract::extractor needs to be kept up-to-date with this macro definition +#[macro_export] +macro_rules! i18n_enum { + ( + $for_enum:ident, + root_key: $root_key:literal, + $($variant_name:ident$variant_pat:tt => $key:literal,)* + ) => { + impl $crate::i18n::I18nEnum for $for_enum { + const ROOT_TRANSLATION_ID: &'static str = $root_key; + + fn translation_id(&self) -> &'static str { + use $for_enum::*; + match self { + $($crate::__i18n_enum_variant_parameters_no_store!($variant_name, $variant_pat) => $key,)* + } + } + + fn full_translation_id(&self) -> &'static str { + use $for_enum::*; + match self { + $($crate::__i18n_enum_variant_parameters_no_store!($variant_name, $variant_pat) => ::core::concat!($root_key, ".", $key),)* + } + } + + fn translation_data(&self) -> $crate::i18n::TranslationData { + trait __TranslatableEnum { + fn __maybe_translate(&self) -> $crate::i18n::TranslationData; + } + impl __TranslatableEnum for T { + fn __maybe_translate(&self) -> $crate::i18n::TranslationData { + $crate::i18n::I18nEnum::translation_data(self) + } + } + trait __NonTranslatableValue { + fn __maybe_translate(&self) -> $crate::i18n::TranslationData; + } + impl __NonTranslatableValue for &T { + fn __maybe_translate(&self) -> $crate::i18n::TranslationData { + $crate::i18n::TranslationData::Literal(::std::string::ToString::to_string(self)) + } + } + use $for_enum::*; + match self { + $( + $crate::__i18n_enum_variant_parameters!($variant_name, $variant_pat) => + $crate::__i18n_enum_variant_values!($root_key, $key, $variant_pat), + )* + } + } + } + }; + + (transparent $for_enum:ident[$field:ident: $field_type:ty]) => { + impl $crate::i18n::I18nEnum for $for_enum { + const ROOT_TRANSLATION_ID: &'static str = <$field_type as $crate::i18n::I18nEnum>::ROOT_TRANSLATION_ID; + + fn translation_id(&self) -> &'static str { + $crate::i18n::I18nEnum::translation_id(&*self.$field) + } + + fn full_translation_id(&self) -> &'static str { + $crate::i18n::I18nEnum::full_translation_id(&*self.$field) + } + + fn translation_data(&self) -> $crate::i18n::TranslationData { + $crate::i18n::I18nEnum::translation_data(&*self.$field) + } + } + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __i18n_enum_variant_parameters_no_store { + ($variant_name:ident, !) => { + $variant_name + }; + ($variant_name:ident, (transparent $_:ident)) => { + $variant_name(_) + }; + ($variant_name:ident, { transparent $_:ident }) => { + $variant_name { .. } + }; + ($variant_name:ident, ($($_:tt)+)) => { + $variant_name(..) + }; + ($variant_name:ident, {$($_:tt)+}) => { + $variant_name { .. } + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __i18n_enum_variant_parameters { + ($variant_name:ident, !) => { + $variant_name + }; + ($variant_name:ident, (transparent $field:ident)) => { + $variant_name($field) + }; + ($variant_name:ident, { transparent $field:ident }) => { + $variant_name { $field, .. } + }; + ($variant_name:ident, ($($field:tt)+)) => { + $variant_name($($field)+) + }; + ($variant_name:ident, {$($field:tt)+}) => { + $variant_name { $($field)+ } + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __i18n_enum_variant_values { + ($root_key:literal, $key:literal, !) => { + $crate::i18n::TranslationData::Translatable { + key: ::std::borrow::Cow::Borrowed(::core::concat!($root_key, ".", $key)), + values: ::std::collections::BTreeMap::new(), + } + }; + ($root_key:literal, $key:literal, (..)) => { + $crate::__i18n_enum_variant_values!($root_key, $key, !) + }; + ($root_key:literal, $key:literal, {..}) => { + $crate::__i18n_enum_variant_values!($root_key, $key, !) + }; + ($root_key:literal, $key:literal, (transparent $field:ident)) => { + $field.__maybe_translate() + }; + ($root_key:literal, $key:literal, { transparent $field:ident }) => { + $field.__maybe_translate() + }; + ($root_key:literal, $key:literal, ($($field:ident),*)) => { + $crate::i18n::TranslationData::Translatable { + key: ::std::borrow::Cow::Borrowed(::core::concat!($root_key, ".", $key)), + values: ::std::collections::BTreeMap::from([ + $((::std::borrow::Cow::Borrowed(::core::stringify!($field)), $field.__maybe_translate()),)* + ]), + } + }; + ($root_key:literal, $key:literal, {$($field:ident),* $(, ..)?}) => { + $crate::__i18n_enum_variant_values!($root_key, $key, ($($field),*)) + }; +} + +#[cfg(test)] +#[doc(hidden)] +pub mod test { + use super::*; + use serde_json::json; + use thiserror::Error; + + #[derive(Debug, Error)] + #[error("Unit Translatable")] + struct UnitTranslatable; + + impl I18nEnum for UnitTranslatable { + const ROOT_TRANSLATION_ID: &'static str = "unit_translatable"; + + fn translation_id(&self) -> &'static str { + "unit" + } + + fn full_translation_id(&self) -> &'static str { + "unit_translatable.unit" + } + + fn translation_data(&self) -> TranslationData { + TranslationData::Translatable { + key: Cow::Borrowed(self.full_translation_id()), + values: BTreeMap::new(), + } + } + } + + #[derive(Debug, Error)] + enum TestEnum { + #[error("Unit")] + Unit, + #[error("Tuple: {0}")] + Tuple(&'static str), + #[error("Translatable Tuple: {0}")] + TranslatableTuple(UnitTranslatable), + #[error("Named: {subfield}")] + Named { subfield: &'static str }, + #[error(transparent)] + DirectUnit(UnitTranslatable), + } + + i18n_enum!( + TestEnum, + root_key: "base", + Unit! => "unit", + Tuple(value) => "tuple", + TranslatableTuple(unit) => "translatable_tuple", + Named { subfield } => "named", + DirectUnit(transparent unit) => "direct_unit", + ); + + fn assert_i18n_eq(x: impl I18nEnum, should_be: serde_json::Value) { + assert_eq!( + serde_json::to_value(x.translation_data()).unwrap(), + should_be + ); + } + + #[test] + fn test() { + assert_i18n_eq( + UnitTranslatable, + json!({ + "key": "unit_translatable.unit", + }), + ); + assert_i18n_eq( + TestEnum::Unit, + json!({ + "key": "base.unit", + }), + ); + assert_i18n_eq( + TestEnum::Tuple("hello"), + json!({ + "key": "base.tuple", + "values": { + "value": "hello", + }, + }), + ); + assert_i18n_eq( + TestEnum::TranslatableTuple(UnitTranslatable), + json!({ + "key": "base.translatable_tuple", + "values": { + "unit": { + "key": "unit_translatable.unit", + }, + }, + }), + ); + assert_i18n_eq( + TestEnum::Named { + subfield: "Subfield", + }, + json!({ + "key": "base.named", + "values": { + "subfield": "Subfield", + } + }), + ); + assert_i18n_eq( + TestEnum::DirectUnit(UnitTranslatable), + json!({ + "key": "unit_translatable.unit", + }), + ) + } +} diff --git a/packages/ariadne/src/lib.rs b/packages/ariadne/src/lib.rs index a1ee76540e..9335e035df 100644 --- a/packages/ariadne/src/lib.rs +++ b/packages/ariadne/src/lib.rs @@ -1,3 +1,4 @@ +pub mod i18n; pub mod ids; pub mod networking; pub mod users; diff --git a/packages/ariadne/src/mod.rs b/packages/ariadne/src/mod.rs deleted file mode 100644 index 4a4251a3e6..0000000000 --- a/packages/ariadne/src/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod ids; -pub mod networking; -pub mod users; diff --git a/packages/daedalus/Cargo.toml b/packages/daedalus/Cargo.toml index 0b2bbdd838..5103c47876 100644 --- a/packages/daedalus/Cargo.toml +++ b/packages/daedalus/Cargo.toml @@ -14,7 +14,7 @@ readme = "README.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -serde = { workspace = true, features = ["derive"] } +serde.workspace = true serde_json.workspace = true chrono = { workspace = true, features = ["serde"] } thiserror.workspace = true diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09465effc9..272166db16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,8 @@ importers: apps/app-playground: {} + apps/ariadne-extract: {} + apps/daedalus_client: dependencies: '@modrinth/daedalus': @@ -386,6 +388,8 @@ importers: packages/app-lib: {} + packages/ariadne: {} + packages/assets: devDependencies: '@modrinth/tooling-config':