From 2174cc1f2b6a6d52f952d9520c3d059f8312660a Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Tue, 26 Aug 2025 15:01:44 -0500 Subject: [PATCH 01/43] Add localized_api_error for marking a function as having localized ApiError returns --- .idea/code.iml | 3 +- Cargo.lock | 165 +++++++++++++------------ Cargo.toml | 4 + apps/labrinth/Cargo.toml | 1 + apps/labrinth/src/routes/mod.rs | 40 +++++- apps/labrinth/src/routes/v3/friends.rs | 2 + packages/labrinth-macros/Cargo.toml | 14 +++ packages/labrinth-macros/src/lib.rs | 40 ++++++ 8 files changed, 187 insertions(+), 82 deletions(-) create mode 100644 packages/labrinth-macros/Cargo.toml create mode 100644 packages/labrinth-macros/src/lib.rs diff --git a/.idea/code.iml b/.idea/code.iml index 4c7179f5df..0fbae95c5e 100644 --- a/.idea/code.iml +++ b/.idea/code.iml @@ -11,9 +11,10 @@ + - + \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 902e2289be..747bda0b78 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]] @@ -654,7 +654,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -694,7 +694,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -735,7 +735,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1114,7 +1114,7 @@ dependencies = [ "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1425,7 +1425,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1470,7 +1470,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1840,7 +1840,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 +1871,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 +1931,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -1942,7 +1942,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2047,7 +2047,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2068,7 +2068,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2078,7 +2078,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 +2091,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2111,7 +2111,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "unicode-xid", ] @@ -2208,7 +2208,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2240,7 +2240,7 @@ checksum = "788160fb30de9cdd857af31c6a2675904b16ece8fc2737b2c7127ba368c9d0f4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2424,7 +2424,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2445,7 +2445,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2467,7 +2467,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2512,7 +2512,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2725,7 +2725,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -2880,7 +2880,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3176,7 +3176,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -3266,7 +3266,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4015,7 +4015,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4371,6 +4371,7 @@ dependencies = [ "itertools 0.14.0", "jemalloc_pprof", "json-patch 4.0.0", + "labrinth-macros", "lettre", "maxminddb", "meilisearch-sdk", @@ -4415,6 +4416,14 @@ dependencies = [ "zxcvbn", ] +[[package]] +name = "labrinth-macros" +version = "0.1.0" +dependencies = [ + "quote", + "syn 2.0.106", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -4707,7 +4716,7 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4752,7 +4761,7 @@ checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -4791,7 +4800,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5170,7 +5179,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5253,7 +5262,7 @@ dependencies = [ "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5844,7 +5853,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5857,7 +5866,7 @@ dependencies = [ "phf_shared 0.12.1", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -5913,7 +5922,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6151,7 +6160,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6207,7 +6216,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6247,7 +6256,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -6744,7 +6753,7 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -7052,7 +7061,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6268b74858287e1a062271b988a0c534bf85bbeb567fe09331bf40ed78113d5" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -7328,7 +7337,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -7367,7 +7376,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -7626,7 +7635,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -7637,7 +7646,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -7712,7 +7721,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -7742,7 +7751,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -7786,7 +7795,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -8120,7 +8129,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -8143,7 +8152,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.104", + "syn 2.0.106", "tokio", "url", ] @@ -8347,7 +8356,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -8358,7 +8367,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -8391,9 +8400,9 @@ 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", @@ -8417,7 +8426,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -8530,7 +8539,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -8649,7 +8658,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.104", + "syn 2.0.106", "tauri-utils", "thiserror 2.0.12", "time", @@ -8667,7 +8676,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "tauri-codegen", "tauri-utils", ] @@ -9146,7 +9155,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -9157,7 +9166,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -9305,7 +9314,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -9597,7 +9606,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -9982,7 +9991,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -10119,7 +10128,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-shared", ] @@ -10154,7 +10163,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -10354,7 +10363,7 @@ checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -10513,7 +10522,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -10524,7 +10533,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -11059,7 +11068,7 @@ dependencies = [ "quote", "serde", "serde_tokenstream", - "syn 2.0.104", + "syn 2.0.106", "xml-rs", ] @@ -11094,7 +11103,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] @@ -11141,7 +11150,7 @@ dependencies = [ "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "zbus_names", "zvariant", "zvariant_utils", @@ -11176,7 +11185,7 @@ checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -11196,7 +11205,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "synstructure", ] @@ -11236,7 +11245,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", ] [[package]] @@ -11350,7 +11359,7 @@ dependencies = [ "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.106", "zvariant_utils", ] @@ -11364,7 +11373,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 94a2821056..398d23a060 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "packages/app-lib", "packages/ariadne", "packages/daedalus", + "packages/labrinth-macros", ] [workspace.package] @@ -79,6 +80,7 @@ indicatif = "0.18.0" itertools = "0.14.0" jemalloc_pprof = "0.8.1" json-patch = { version = "4.0.0", default-features = false } +labrinth-macros = { path = "packages/labrinth-macros" } lettre = { version = "0.11.18", default-features = false, features = [ "builder", "hostname", @@ -101,6 +103,7 @@ png = "0.17.16" prometheus = "0.14.0" quartz_nbt = "0.2.9" quick-xml = "0.38.1" +quote = "1.0.40" rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9 rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9 redis = "0.32.4" @@ -136,6 +139,7 @@ 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"] } sysinfo = { version = "0.36.1", default-features = false } tar = "0.4.44" tauri = "2.7.0" diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index c0dc09a90b..c2a167b083 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -129,6 +129,7 @@ rusty-money.workspace = true json-patch.workspace = true ariadne.workspace = true +labrinth-macros.workspace = true clap = { workspace = true, features = ["derive"] } diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 4b3225bd3f..fe5b21d717 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -5,7 +5,10 @@ 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 actix_web::http::header::{ + AcceptLanguage, ContentLanguage, Header, LanguageTag, QualityItem, +}; +use actix_web::{HttpRequest, HttpResponse, ResponseError, web}; use futures::FutureExt; pub mod internal; @@ -151,6 +154,25 @@ pub enum ApiError { impl ApiError { pub fn as_api_error<'a>(&self) -> crate::models::error::ApiError<'a> { + self.as_api_error_with_description(self.to_string()) + } + + pub fn as_localized_api_error<'a>( + &self, + language: &LanguageTag, + ) -> crate::models::error::ApiError<'a> { + let description = if language.primary_language() == "en" { + self.to_string() + } else { + format!("In language {language}! {self}") + }; + self.as_api_error_with_description(description) + } + + fn as_api_error_with_description<'a>( + &self, + description: String, + ) -> crate::models::error::ApiError<'a> { crate::models::error::ApiError { error: match self { ApiError::Env(..) => "environment_error", @@ -183,12 +205,24 @@ impl ApiError { ApiError::RateLimitError(..) => "ratelimit_error", ApiError::Stripe(..) => "stripe_error", }, - description: self.to_string(), + description, } } + + pub fn localized_error_response(&self, req: &HttpRequest) -> HttpResponse { + let language = AcceptLanguage::parse(req) + .ok() + .and_then(|x| x.preference().into_item()) + .unwrap_or_else(|| LanguageTag::parse("en-US").unwrap()); + let body = self.as_localized_api_error(&language); + + HttpResponse::build(self.status_code()) + .insert_header(ContentLanguage(vec![QualityItem::max(language)])) + .json(body) + } } -impl actix_web::ResponseError for ApiError { +impl ResponseError for ApiError { fn status_code(&self) -> StatusCode { match self { ApiError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR, diff --git a/apps/labrinth/src/routes/v3/friends.rs b/apps/labrinth/src/routes/v3/friends.rs index fb307fb039..8964c6bfe6 100644 --- a/apps/labrinth/src/routes/v3/friends.rs +++ b/apps/labrinth/src/routes/v3/friends.rs @@ -14,6 +14,7 @@ use crate::sync::status::get_user_status; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use ariadne::networking::message::ServerToClientMessage; use chrono::Utc; +use labrinth_macros::localized_api_error; use sqlx::PgPool; pub fn config(cfg: &mut web::ServiceConfig) { @@ -23,6 +24,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { } #[post("friend/{id}")] +#[localized_api_error] pub async fn add_friend( req: HttpRequest, info: web::Path<(String,)>, diff --git a/packages/labrinth-macros/Cargo.toml b/packages/labrinth-macros/Cargo.toml new file mode 100644 index 0000000000..6183016519 --- /dev/null +++ b/packages/labrinth-macros/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "labrinth-macros" +version = "0.1.0" +edition.workspace = true + +[lib] +proc-macro = true + +[dependencies] +syn.workspace = true +quote.workspace = true + +[lints] +workspace = true diff --git a/packages/labrinth-macros/src/lib.rs b/packages/labrinth-macros/src/lib.rs new file mode 100644 index 0000000000..323cc42740 --- /dev/null +++ b/packages/labrinth-macros/src/lib.rs @@ -0,0 +1,40 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{FnArg, ItemFn, PatType, parse_macro_input, parse_quote}; + +#[proc_macro_attribute] +pub fn localized_api_error( + _args: TokenStream, + input: TokenStream, +) -> TokenStream { + let function = parse_macro_input!(input as ItemFn); + + let mut adjusted_sig = function.sig.clone(); + adjusted_sig.output = parse_quote! { + -> actix_web::HttpResponse + }; + + let vis = function.vis; + let return_type = function.sig.output; + let body = function.block; + let Some(FnArg::Typed(PatType { pat: req, .. })) = + function.sig.inputs.first() + else { + return quote! { + compile_error!("Expected first parameter to be HttpRequest"); + } + .into(); + }; + + quote! { + #vis #adjusted_sig { + let mut handler = async || #return_type #body; + let result = handler().await; + match result { + Ok(resp) => resp, + Err(e) => e.localized_error_response(&#req), + } + } + } + .into() +} From 3916a49309c826c19f8537be5864884a1fa0d8cd Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 28 Aug 2025 19:08:40 -0500 Subject: [PATCH 02/43] Do too many things. Why did I wait so long to commit? --- .github/workflows/labrinth-docker.yml | 4 + .idea/code.iml | 2 +- Cargo.lock | 182 +++++++++++- Cargo.toml | 6 +- apps/labrinth/Cargo.toml | 5 +- apps/labrinth/src/auth/mod.rs | 98 ++++--- apps/labrinth/src/auth/oauth/errors.rs | 3 +- apps/labrinth/src/file_hosting/mod.rs | 17 +- apps/labrinth/src/lib.rs | 3 + apps/labrinth/src/models/error.rs | 3 +- apps/labrinth/src/routes/maven.rs | 2 + apps/labrinth/src/routes/mod.rs | 260 +++++++++++------- apps/labrinth/src/routes/not_found.rs | 13 +- apps/labrinth/src/routes/v3/friends.rs | 4 +- apps/labrinth/src/util/ratelimit.rs | 6 +- .../Cargo.toml | 6 +- packages/ariadne-macros/src/i18n_enum.rs | 228 +++++++++++++++ packages/ariadne-macros/src/lib.rs | 81 ++++++ packages/ariadne/Cargo.toml | 5 + packages/ariadne/src/i18n.rs | 9 + packages/ariadne/src/lib.rs | 1 + packages/ariadne/src/mod.rs | 3 - packages/labrinth-macros/src/lib.rs | 40 --- 23 files changed, 756 insertions(+), 225 deletions(-) rename packages/{labrinth-macros => ariadne-macros}/Cargo.toml (67%) create mode 100644 packages/ariadne-macros/src/i18n_enum.rs create mode 100644 packages/ariadne-macros/src/lib.rs create mode 100644 packages/ariadne/src/i18n.rs delete mode 100644 packages/ariadne/src/mod.rs delete mode 100644 packages/labrinth-macros/src/lib.rs diff --git a/.github/workflows/labrinth-docker.yml b/.github/workflows/labrinth-docker.yml index 6d3686595e..e3191c0f23 100644 --- a/.github/workflows/labrinth-docker.yml +++ b/.github/workflows/labrinth-docker.yml @@ -7,11 +7,15 @@ on: paths: - .github/workflows/labrinth-docker.yml - 'apps/labrinth/**' + - 'packages/ariadne/**' + - 'packages/ariadne-macros/**' pull_request: types: [opened, synchronize] paths: - .github/workflows/labrinth-docker.yml - 'apps/labrinth/**' + - 'packages/ariadne/**' + - 'packages/ariadne-macros/**' merge_group: types: [checks_requested] diff --git a/.idea/code.iml b/.idea/code.iml index 0fbae95c5e..311d1ac66a 100644 --- a/.idea/code.iml +++ b/.idea/code.iml @@ -11,7 +11,7 @@ - + diff --git a/Cargo.lock b/Cargo.lock index 747bda0b78..d67a19e622 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -449,6 +449,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -476,6 +482,7 @@ dependencies = [ name = "ariadne" version = "0.1.0" dependencies = [ + "ariadne-macros", "chrono", "either", "rand 0.8.5", @@ -487,6 +494,15 @@ dependencies = [ "uuid 1.17.0", ] +[[package]] +name = "ariadne-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -967,6 +983,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" +[[package]] +name = "base62" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0104d4d8d15e458f21dcd027ea350bf38e4364954909402f4da075aca8d0f136" +dependencies = [ + "rustversion", +] + [[package]] name = "base64" version = "0.13.1" @@ -1145,6 +1170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" dependencies = [ "memchr", + "serde", ] [[package]] @@ -3195,6 +3221,30 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +[[package]] +name = "globset" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -3861,6 +3911,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.9", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.6" @@ -4108,6 +4174,15 @@ dependencies = [ "nom 8.0.0", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -4371,7 +4446,6 @@ dependencies = [ "itertools 0.14.0", "jemalloc_pprof", "json-patch 4.0.0", - "labrinth-macros", "lettre", "maxminddb", "meilisearch-sdk", @@ -4383,6 +4457,7 @@ dependencies = [ "redis", "regex", "reqwest", + "rust-i18n", "rust-s3", "rust_decimal", "rust_iso3166", @@ -4416,14 +4491,6 @@ dependencies = [ "zxcvbn", ] -[[package]] -name = "labrinth-macros" -version = "0.1.0" -dependencies = [ - "quote", - "syn 2.0.106", -] - [[package]] name = "language-tags" version = "0.3.2" @@ -5060,6 +5127,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "normpath" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8911957c4b1549ac0dc74e30db9c8b0e66ddcd6d7acc33098f4c63a64a6d7ed" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "notify" version = "8.2.0" @@ -6171,9 +6247,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", ] @@ -6990,6 +7066,60 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-i18n" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" +dependencies = [ + "globwalk", + "once_cell", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" +dependencies = [ + "glob", + "once_cell", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn 2.0.106", +] + +[[package]] +name = "rust-i18n-support" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "lazy_static", + "normpath", + "once_cell", + "proc-macro2", + "regex", + "serde", + "serde_json", + "serde_yaml", + "siphasher 1.0.1", + "toml 0.8.23", + "triomphe", +] + [[package]] name = "rust-ini" version = "0.21.2" @@ -7798,6 +7928,19 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.10.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serialize-to-javascript" version = "0.1.1" @@ -9681,6 +9824,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "triomphe" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -9842,6 +9996,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 398d23a060..852d0e677e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,8 +7,8 @@ members = [ "apps/labrinth", "packages/app-lib", "packages/ariadne", + "packages/ariadne-macros", "packages/daedalus", - "packages/labrinth-macros", ] [workspace.package] @@ -80,7 +80,7 @@ indicatif = "0.18.0" itertools = "0.14.0" jemalloc_pprof = "0.8.1" json-patch = { version = "4.0.0", default-features = false } -labrinth-macros = { path = "packages/labrinth-macros" } +ariadne-macros = { path = "packages/ariadne-macros" } lettre = { version = "0.11.18", default-features = false, features = [ "builder", "hostname", @@ -100,6 +100,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" @@ -112,6 +113,7 @@ reqwest = { version = "0.12.22", default-features = false } rgb = "0.8.52" rust_decimal = { version = "1.37.2", features = ["serde-with-float", "serde-with-str"] } rust_iso3166 = "0.1.14" +rust-i18n = "3.1.5" rust-s3 = { version = "0.35.1", default-features = false, features = [ "fail-on-err", "tags", diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index c2a167b083..3e0ad62db3 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -124,12 +124,13 @@ lettre.workspace = true rust_iso3166.workspace = true +rust-i18n.workspace = true + async-stripe = { workspace = true, features = ["billing", "checkout", "connect", "webhook-events"] } rusty-money.workspace = true json-patch.workspace = true -ariadne.workspace = true -labrinth-macros.workspace = true +ariadne = { workspace = true, features = ["labrinth"] } clap = { workspace = true, features = ["derive"] } diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs index a22fd65cf3..ea72c47a05 100644 --- a/apps/labrinth/src/auth/mod.rs +++ b/apps/labrinth/src/auth/mod.rs @@ -14,46 +14,78 @@ 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 actix_web::{HttpResponse, ResponseError}; use actix_web::http::StatusCode; use thiserror::Error; +use ariadne::i18n::I18nEnum; +use crate::labrinth_error_type; -#[derive(Error, Debug)] +// TODO add fields +#[derive(Error, I18nEnum, Debug)] +#[i18n_root_key("error.unauthorized")] pub enum AuthenticationError { - #[error("Environment Error")] + #[translation_id("environment_error")] + // #[error("Environment Error")] Env(#[from] dotenvy::Error), - #[error("An unknown database error occurred: {0}")] + + #[translation_id("database_error")] + // #[error("An unknown database error occurred: {0}")] Sqlx(#[from] sqlx::Error), - #[error("Database Error: {0}")] + + #[translation_id("database_error")] + // #[error("Database Error: {0}")] Database(#[from] crate::database::models::DatabaseError), - #[error("Error while parsing JSON: {0}")] + + #[translation_id("invalid_input")] + // #[error("Error while parsing JSON: {0}")] SerDe(#[from] serde_json::Error), - #[error("Error while communicating to external provider")] + + #[translation_id("network_error")] + // #[error("Error while communicating to external provider")] Reqwest(#[from] reqwest::Error), - #[error("Error uploading user profile picture")] + + #[translation_id("file_hosting")] + // #[error("Error uploading user profile picture")] FileHosting(#[from] FileHostingError), - #[error("Error while decoding PAT: {0}")] + + #[translation_id("decoding_error")] + // #[error("Error while decoding PAT: {0}")] Decoding(#[from] ariadne::ids::DecodingError), - #[error("{0}")] + + #[translation_id("mail_error")] + // #[error("{0}")] Mail(#[from] email::MailError), - #[error("Invalid Authentication Credentials")] + + #[translation_id("invalid_credentials")] + // #[error("Invalid Authentication Credentials")] InvalidCredentials, - #[error("Authentication method was not valid")] + + #[translation_id("invalid_auth_method")] + // #[error("Authentication method was not valid")] InvalidAuthMethod, - #[error("GitHub Token from incorrect Client ID")] + + #[translation_id("invalid_client_id")] + // #[error("GitHub Token from incorrect Client ID")] InvalidClientId, - #[error( - "User email is already registered on Modrinth. Try 'Forgot password' to access your account." - )] + + #[translation_id("duplicate_user")] + // #[error( + // "User email is already registered on Modrinth. Try 'Forgot password' to access your account." + // )] DuplicateUser, - #[error("Invalid state sent, you probably need to get a new websocket")] + + #[translation_id("socket")] + // #[error("Invalid state sent, you probably need to get a new websocket")] SocketError, - #[error("Invalid callback URL specified")] + + #[translation_id("url_error")] + // #[error("Invalid callback URL specified")] Url, } -impl actix_web::ResponseError for AuthenticationError { +labrinth_error_type!(AuthenticationError); + +impl ResponseError for AuthenticationError { fn status_code(&self) -> StatusCode { match self { AuthenticationError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR, @@ -80,31 +112,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..2e3a7efe63 100644 --- a/apps/labrinth/src/auth/oauth/errors.rs +++ b/apps/labrinth/src/auth/oauth/errors.rs @@ -104,12 +104,13 @@ impl actix_web::ResponseError for OAuthError { } else { HttpResponse::build(self.status_code()).json(ApiError { error: &self.error_type.error_name(), - description: self.error_type.to_string(), + description: self.error_type.to_string().into(), }) } } } +// TODO: Reference in an ApiError variant #[derive(thiserror::Error, Debug)] pub enum OAuthErrorType { #[error(transparent)] diff --git a/apps/labrinth/src/file_hosting/mod.rs b/apps/labrinth/src/file_hosting/mod.rs index 7de0ff6a9e..548b40ac5b 100644 --- a/apps/labrinth/src/file_hosting/mod.rs +++ b/apps/labrinth/src/file_hosting/mod.rs @@ -5,16 +5,25 @@ mod mock; mod s3_host; use bytes::Bytes; +use ariadne::i18n::I18nEnum; pub use mock::MockHost; pub use s3_host::{S3BucketConfig, S3Host}; -#[derive(Error, Debug)] +#[derive(Error, I18nEnum, Debug)] +#[i18n_root_key("error.file_hosting_error")] pub enum FileHostingError { - #[error("S3 error when {0}: {1}")] + #[translation_id("s3")] + #[translate_fields(action = 0, cause = 1)] // TODO: Use an I18nEnum instead of a String + // #[error("S3 error when {0}: {1}")] S3Error(&'static str, s3::error::S3Error), - #[error("File system error in file hosting: {0}")] + + #[translation_id("file_system")] + #[translate_fields(cause = 0)] + // #[error("File system error in file hosting: {0}")] FileSystemError(#[from] std::io::Error), - #[error("Invalid Filename")] + + #[translation_id("invalid_filename")] + // #[error("Invalid Filename")] InvalidFilename, } diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index 6fc0e8d88b..fa14d55b44 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 rust_i18n::i18n; use util::cors::default_cors; use crate::background_task::update_versions; @@ -35,6 +36,8 @@ pub mod sync; pub mod util; pub mod validate; +i18n!(); + #[derive(Clone)] pub struct Pepper { pub pepper: String, diff --git a/apps/labrinth/src/models/error.rs b/apps/labrinth/src/models/error.rs index 28f737c16c..2c50c1411b 100644 --- a/apps/labrinth/src/models/error.rs +++ b/apps/labrinth/src/models/error.rs @@ -1,8 +1,9 @@ +use std::borrow::Cow; use serde::{Deserialize, Serialize}; /// An error returned by the API #[derive(Serialize, Deserialize)] pub struct ApiError<'a> { pub error: &'a str, - pub description: String, + pub description: Cow<'a, str>, } diff --git a/apps/labrinth/src/routes/maven.rs b/apps/labrinth/src/routes/maven.rs index 878f6dabcc..ed822af0a0 100644 --- a/apps/labrinth/src/routes/maven.rs +++ b/apps/labrinth/src/routes/maven.rs @@ -15,6 +15,7 @@ use actix_web::{HttpRequest, HttpResponse, get, route, web}; use sqlx::PgPool; use std::collections::HashSet; use yaserde::YaSerialize; +use ariadne::i18n::localized_labrinth_error; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(maven_metadata); @@ -69,6 +70,7 @@ pub struct MavenPom { } #[get("maven/modrinth/{id}/maven-metadata.xml")] +#[localized_labrinth_error] pub async fn maven_metadata( req: HttpRequest, params: web::Path<(String,)>, diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index fe5b21d717..76b7b1ed5b 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -5,11 +5,10 @@ use crate::util::env::parse_strings_from_var; use actix_cors::Cors; use actix_files::Files; use actix_web::http::StatusCode; -use actix_web::http::header::{ - AcceptLanguage, ContentLanguage, Header, LanguageTag, QualityItem, -}; -use actix_web::{HttpRequest, HttpResponse, ResponseError, web}; +use actix_web::http::header::Header; +use actix_web::{HttpResponse, ResponseError, web}; use futures::FutureExt; +use ariadne::i18n::I18nEnum; pub mod internal; pub mod v2; @@ -88,140 +87,201 @@ pub fn root_config(cfg: &mut web::ServiceConfig) { ); } -#[derive(thiserror::Error, Debug)] +#[derive(thiserror::Error, I18nEnum, Debug)] +#[i18n_root_key("error")] pub enum ApiError { - #[error("Environment Error")] + #[translation_id("environment_error")] + // #[error("Environment Error")] Env(#[from] dotenvy::Error), - #[error("Error while uploading file: {0}")] + + #[translation_id("file_hosting_error")] + #[translate_fields(cause = translate(0))] + // #[error("Error while uploading file: {0}")] FileHosting(#[from] FileHostingError), - #[error("Database Error: {0}")] + + #[translation_id("database_error")] + #[translate_fields(cause = translate(0))] + // #[error("Database Error: {0}")] Database(#[from] crate::database::models::DatabaseError), - #[error("Database Error: {0}")] + + #[translation_id("database_error")] + #[translate_fields(cause = 0)] + // #[error("Database Error: {0}")] SqlxDatabase(#[from] sqlx::Error), - #[error("Database Error: {0}")] + + #[translation_id("database_error")] + #[translate_fields(cause = 0)] + // #[error("Database Error: {0}")] RedisDatabase(#[from] redis::RedisError), - #[error("Clickhouse Error: {0}")] + + #[translation_id("clickhouse_error")] + #[translate_fields(cause = 0)] + // #[error("Clickhouse Error: {0}")] Clickhouse(#[from] clickhouse::error::Error), - #[error("Internal server error: {0}")] + + #[translation_id("xml_error")] + #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String + // #[error("Internal server error: {0}")] Xml(String), - #[error("Deserialization error: {0}")] + + #[translation_id("json_error")] + #[translate_fields(cause = 0)] + // #[error("Deserialization error: {0}")] Json(#[from] serde_json::Error), - #[error("Authentication Error: {0}")] + + #[translation_id("unauthorized")] + #[translate_fields(cause = translate(0))] + // #[error("Authentication Error: {0}")] Authentication(#[from] crate::auth::AuthenticationError), - #[error("Authentication Error: {0}")] + + #[translation_id("unauthorized")] + #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String + // #[error("Authentication Error: {0}")] CustomAuthentication(String), - #[error("Invalid Input: {0}")] + + #[translation_id("invalid_input")] + #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String + // #[error("Invalid Input: {0}")] InvalidInput(String), - #[error("Error while validating input: {0}")] + + // TODO: Perhaps remove this in favor of InvalidInput? + #[translation_id("invalid_input")] + #[translate_fields(cause = 0)] + // #[error("Error while validating input: {0}")] Validation(String), - #[error("Search Error: {0}")] + + #[translation_id("search_error")] + #[translate_fields(cause = 0)] + // #[error("Search Error: {0}")] Search(#[from] meilisearch_sdk::errors::Error), - #[error("Indexing Error: {0}")] + + #[translation_id("indexing_error")] + #[translate_fields(cause = translate(0))] + // #[error("Indexing Error: {0}")] Indexing(#[from] crate::search::indexing::IndexingError), - #[error("Payments Error: {0}")] + + #[translation_id("payments_error")] + #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String + // #[error("Payments Error: {0}")] Payments(String), - #[error("Discord Error: {0}")] + + #[translation_id("discord_error")] + #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String + // #[error("Discord Error: {0}")] Discord(String), - #[error("Captcha Error. Try resubmitting the form.")] + + #[translation_id("turnstile_error")] + // #[error("Captcha Error. Try resubmitting the form.")] Turnstile, - #[error("Error while decoding Base62: {0}")] + + #[translation_id("decoding_error")] + #[translate_fields(cause = translate(0))] + // #[error("Error while decoding Base62: {0}")] Decoding(#[from] ariadne::ids::DecodingError), - #[error("Image Parsing Error: {0}")] + + #[translation_id("invalid_image")] + #[translate_fields(cause = 0)] + // #[error("Image Parsing Error: {0}")] ImageParse(#[from] image::ImageError), - #[error("Password Hashing Error: {0}")] + + #[translation_id("password_hashing_error")] + #[translate_fields(cause = 0)] + // #[error("Password Hashing Error: {0}")] PasswordHashing(#[from] argon2::password_hash::Error), - #[error("{0}")] + + #[translation_id("mail_error")] + #[translate_fields(cause = translate(0))] + // #[error("{0}")] Mail(#[from] crate::auth::email::MailError), - #[error("Error while rerouting request: {0}")] + + #[translation_id("reroute_error")] + #[translate_fields(cause = 0)] + // #[error("Error while rerouting request: {0}")] Reroute(#[from] reqwest::Error), - #[error("Unable to read Zip Archive: {0}")] + + #[translation_id("zip_error")] + #[translate_fields(cause = 0)] + // #[error("Unable to read Zip Archive: {0}")] Zip(#[from] zip::result::ZipError), - #[error("IO Error: {0}")] + + #[translation_id("io_error")] + #[translate_fields(cause = 0)] + // #[error("IO Error: {0}")] Io(#[from] std::io::Error), - #[error("Resource not found")] + + // TODO: Add route not found + #[translation_id("not_found")] + // #[error("Resource not found")] NotFound, - #[error("Conflict: {0}")] + + #[translation_id("conflict")] + #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String + // #[error("Conflict: {0}")] Conflict(String), - #[error("External tax compliance API Error")] + + #[translation_id("tax_compliance_api_error")] + // #[error("External tax compliance API Error")] TaxComplianceApi, - #[error( - "You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining." - )] + + #[translation_id("ratelimit_error")] + #[translate_fields(wait_ms = 0, total_allowed_requests = 1)] + // #[error( + // "You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining." + // )] RateLimitError(u128, u32), - #[error("Error while interacting with payment processor: {0}")] + + #[translation_id("stripe_error")] + #[translate_fields(cause = 0)] + // #[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> { - self.as_api_error_with_description(self.to_string()) - } +#[macro_export] +macro_rules! labrinth_error_type { + ($error_enum:ty) => { + impl $error_enum { + pub fn as_api_error<'a>(&self) -> crate::models::error::ApiError<'a> { + self.as_api_error_with_description(self.to_string().into()) + } - pub fn as_localized_api_error<'a>( - &self, - language: &LanguageTag, - ) -> crate::models::error::ApiError<'a> { - let description = if language.primary_language() == "en" { - self.to_string() - } else { - format!("In language {language}! {self}") - }; - self.as_api_error_with_description(description) - } + pub fn as_localized_api_error<'a>( + &self, + language: &actix_web::http::header::LanguageTag, + ) -> crate::models::error::ApiError<'a> { + let description = self.translated_message(language.as_str()); + self.as_api_error_with_description(description) + } - fn as_api_error_with_description<'a>( - &self, - description: String, - ) -> 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", - }, - description, - } - } + fn as_api_error_with_description<'a>( + &self, + description: std::borrow::Cow<'a, str>, + ) -> crate::models::error::ApiError<'a> { + crate::models::error::ApiError { + error: self.translation_id(), + description, + } + } - pub fn localized_error_response(&self, req: &HttpRequest) -> HttpResponse { - let language = AcceptLanguage::parse(req) - .ok() - .and_then(|x| x.preference().into_item()) - .unwrap_or_else(|| LanguageTag::parse("en-US").unwrap()); - let body = self.as_localized_api_error(&language); + pub fn localized_error_response(&self, req: &actix_web::HttpRequest) -> actix_web::HttpResponse { + use actix_web::http::header::{ + AcceptLanguage, ContentLanguage, Header, LanguageTag, QualityItem, + }; + let language = AcceptLanguage::parse(req) + .ok() + .and_then(|x| x.preference().into_item()) + .unwrap_or_else(|| LanguageTag::parse("en-US").unwrap()); + let body = self.as_localized_api_error(&language); - HttpResponse::build(self.status_code()) - .insert_header(ContentLanguage(vec![QualityItem::max(language)])) - .json(body) - } + actix_web::HttpResponse::build(self.status_code()) + .insert_header(ContentLanguage(vec![QualityItem::max(language)])) + .json(body) + } + } + }; } +labrinth_error_type!(ApiError); + impl ResponseError for ApiError { fn status_code(&self) -> StatusCode { match self { diff --git a/apps/labrinth/src/routes/not_found.rs b/apps/labrinth/src/routes/not_found.rs index 2da930bd76..55ab4277e6 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 actix_web::{HttpRequest, HttpResponse}; +use crate::routes::ApiError; -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(req: HttpRequest) -> HttpResponse { + ApiError::NotFound.localized_error_response(&req) } diff --git a/apps/labrinth/src/routes/v3/friends.rs b/apps/labrinth/src/routes/v3/friends.rs index 8964c6bfe6..5914a7ec42 100644 --- a/apps/labrinth/src/routes/v3/friends.rs +++ b/apps/labrinth/src/routes/v3/friends.rs @@ -14,8 +14,8 @@ use crate::sync::status::get_user_status; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use ariadne::networking::message::ServerToClientMessage; use chrono::Utc; -use labrinth_macros::localized_api_error; use sqlx::PgPool; +use ariadne::i18n::localized_labrinth_error; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(add_friend); @@ -24,7 +24,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { } #[post("friend/{id}")] -#[localized_api_error] +#[localized_labrinth_error] pub async fn add_friend( req: HttpRequest, info: web::Path<(String,)>, diff --git a/apps/labrinth/src/util/ratelimit.rs b/apps/labrinth/src/util/ratelimit.rs index c64c801fe0..84f41da2a8 100644 --- a/apps/labrinth/src/util/ratelimit.rs +++ b/apps/labrinth/src/util/ratelimit.rs @@ -2,7 +2,7 @@ use crate::database::redis::RedisPool; use crate::routes::ApiError; use crate::util::env::parse_var; use actix_web::{ - Error, ResponseError, + Error, body::{EitherBody, MessageBody}, dev::{ServiceRequest, ServiceResponse}, middleware::Next, @@ -187,7 +187,7 @@ pub async fn rate_limit_middleware( decision.retry_after_ms.unwrap_or(0) as u128, decision.limit, ) - .error_response(); + .localized_error_response(req.request()); // Add rate limit headers let headers = response.headers_mut(); @@ -228,7 +228,7 @@ pub async fn rate_limit_middleware( let response = ApiError::CustomAuthentication( "Unable to obtain user IP address!".to_string(), ) - .error_response(); + .localized_error_response(req.request()); Ok(req.into_response(response.map_into_right_body())) } diff --git a/packages/labrinth-macros/Cargo.toml b/packages/ariadne-macros/Cargo.toml similarity index 67% rename from packages/labrinth-macros/Cargo.toml rename to packages/ariadne-macros/Cargo.toml index 6183016519..e15d5eafba 100644 --- a/packages/labrinth-macros/Cargo.toml +++ b/packages/ariadne-macros/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "labrinth-macros" +name = "ariadne-macros" version = "0.1.0" edition.workspace = true @@ -9,6 +9,10 @@ proc-macro = true [dependencies] syn.workspace = true quote.workspace = true +proc-macro2.workspace = true + +[features] +labrinth = [] [lints] workspace = true diff --git a/packages/ariadne-macros/src/i18n_enum.rs b/packages/ariadne-macros/src/i18n_enum.rs new file mode 100644 index 0000000000..a348b1f029 --- /dev/null +++ b/packages/ariadne-macros/src/i18n_enum.rs @@ -0,0 +1,228 @@ +use proc_macro::TokenStream; +use quote::{quote, ToTokens, TokenStreamExt}; +use std::collections::HashMap; +use std::mem; +use syn::parse::{Parse, ParseStream}; +use syn::{parenthesized, Attribute, Data, DeriveInput, Error, Fields, Ident, LitStr, Member, Result, Token, Variant}; + +pub fn generate_impls(input: DeriveInput) -> Result { + let enum_name = input.ident; + let i18n_root_key = find_i18n_root_key(&enum_name, input.attrs)?; + let Data::Enum(enum_data) = input.data else { + return Err(Error::new( + enum_name.span(), + "I18nEnum only supports enums. Please place the macro on the underlying error type.", + )); + }; + + let variants = parse_variants(enum_data.variants)?; + + let translation_id_cases = variants.iter().map(|variant| { + let name = &variant.name; + let pattern_format = variant.pattern_format; + let id = variant.translation_id.as_ref().unwrap(); + quote! { + Self::#name #pattern_format => #id, + } + }); + + let message_cases = variants.iter().map(|variant| { + let name = &variant.name; + let pattern_format = variant.pattern_format; + let message_key = format!("{i18n_root_key}.{}", variant.translation_id.as_ref().unwrap().value()); + let params = variant.translate_fields + .as_ref() + .unwrap() + .0 + .iter() + .map(|(param_name, field)| quote! { #param_name = #field }); + quote! { + x @ Self::#name #pattern_format => + ::rust_i18n::t!(#message_key, locale = locale, #(#params),*), + } + }); + + let result = quote! { + impl ::ariadne::i18n::I18nEnum for #enum_name { + fn translation_id(&self) -> &'static str { + match self { + #(#translation_id_cases)* + } + } + + fn translated_message(&self, locale: &str) -> ::std::borrow::Cow<'_, str> { + match self { + #(#message_cases)* + } + } + } + + impl ::std::fmt::Display for #enum_name { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + write!(f, "{}", self.translated_message("en")) + } + } + } + .into(); + + Ok(result) +} + +fn find_i18n_root_key(enum_name: &Ident, attrs: Vec) -> Result { + for attr in attrs { + if attr.path().is_ident("i18n_root_key") { + let key: LitStr = attr.parse_args()?; + return Ok(key.value()); + } + } + Err(Error::new(enum_name.span(), "Missing #[i18n_root_key] attribute")) +} + +fn parse_variants( + variants: impl IntoIterator, +) -> Result> { + let mut result = variants.into_iter() + .map(VariantData::parse) + .collect(); + validate_variants(&mut result)?; + Ok(result) +} + +struct VariantData { + name: Ident, + pattern_format: PatternFormat, + translation_id: Result, + translate_fields: Result, +} + +impl VariantData { + fn parse(variant: Variant) -> Self { + let name = variant.ident; + let mut translation_id = None; + let mut translate_fields = None; + for attr in variant.attrs { + if attr.path().is_ident("translation_id") { + translation_id = Some(attr.parse_args()); + } else if attr.path().is_ident("translate_fields") { + translate_fields = Some(attr.parse_args()); + + } + } + Self { + translation_id: translation_id.unwrap_or_else(|| Err(Error::new( + name.span(), + format!("Missing #[translation_id] for variant {}", name), + ))), + translate_fields: translate_fields.unwrap_or_else(|| Ok(Default::default())), + + name, + pattern_format: PatternFormat::from_variant(&variant.fields), + } + } +} + +#[derive(Copy, Clone)] +enum PatternFormat { + Named, + Tuple, + Unit, +} + +impl PatternFormat { + fn from_variant(fields: &Fields) -> Self { + match fields { + Fields::Named(_) => Self::Named, + Fields::Unnamed(_) => Self::Tuple, + Fields::Unit => Self::Unit, + } + } +} + +impl ToTokens for PatternFormat { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + match self { + PatternFormat::Named => tokens.append_all(quote! { { .. } }), + PatternFormat::Tuple => tokens.append_all(quote! { (..) }), + PatternFormat::Unit => {} + } + } +} + +#[derive(Default)] +struct TranslateFields(HashMap); + +impl Parse for TranslateFields { + fn parse(input: ParseStream) -> Result { + let result = input.parse_terminated(|input| { + let key = input.parse::()?; + input.parse::()?; + let value = input.parse::()?; + Ok((key, value)) + }, Token![,])?; + Ok(Self(result.into_iter().collect())) + } +} + +struct TranslateField { + member: Member, + translated: bool, +} + +impl ToTokens for TranslateField { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let member = &self.member; + let new_tokens = if self.translated { + quote! { + ::ariadne::i18n::I18nEnum::translated_message(&self.#member, locale) + } + } else { + quote! { + self.#member + } + }; + tokens.append_all(new_tokens); + } +} + +impl Parse for TranslateField { + fn parse(input: ParseStream) -> Result { + mod kw { + syn::custom_keyword!(translate); + } + let translated = input.peek(kw::translate); + let member = if translated { + input.parse::()?; + let content; + parenthesized!(content in input); + content.parse() + } else { + input.parse() + }?; + Ok(Self { + member, + translated, + }) + } +} + +fn validate_variants(variants: &mut Vec) -> Result<()> { + fn take_err(variant_name: &Ident, value: &mut Result) -> Option { + match value { + Ok(_) => None, + Err(e) => Some(mem::replace(e, Error::new(variant_name.span(), ""))), + } + } + + variants + .iter_mut() + .flat_map(|x| vec![ + take_err(&x.name, &mut x.translation_id), + take_err(&x.name, &mut x.translate_fields), + ]) + .filter_map(|x| x) + .reduce(|mut a, b| { + a.extend(b); + a + }) + .map_or(Ok(()), Err) +} diff --git a/packages/ariadne-macros/src/lib.rs b/packages/ariadne-macros/src/lib.rs new file mode 100644 index 0000000000..12780634e0 --- /dev/null +++ b/packages/ariadne-macros/src/lib.rs @@ -0,0 +1,81 @@ +mod i18n_enum; + +use proc_macro::TokenStream; +use syn::{DeriveInput, parse_macro_input}; + +/// This derive macro defines three attributes: +/// - `i18n_root_key`: Placed on the enum itself to specify a root translation key +/// - `error_id`: Placed on each enum element to define a string ID for errors. This will be used +/// for translation keys +/// - `translate_fields`: Optionally placed on each enum element to pass field names to the +/// translation, interpolated with `%(field_name)` in the translations. The member name can be +/// surrounded with `translate` to recursively translate it +/// +/// Example: +/// ``` +/// #[derive(I18nEnum)] +/// #[i18n_root_key("error.example")] +/// enum ExampleEnum { +/// #[error_id("example")] +/// #[field_names(cause = 0)] +/// Example(SomeEnum), +/// +/// #[error_id("translated_example")] +/// #[field_names(cause = translate(0))] +/// TranslatedExample(SomeTranslatableEnum), +/// } +/// ``` +#[proc_macro_derive( + I18nEnum, + attributes(i18n_root_key, translation_id, translate_fields) +)] +pub fn i18n_enum(item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as DeriveInput); + i18n_enum::generate_impls(input) + .unwrap_or_else(|err| err.to_compile_error().into()) +} + +// This exists purely to work around https://github.com/actix/actix-web/issues/2925 +#[cfg(feature = "labrinth")] +#[proc_macro_attribute] +pub fn localized_labrinth_error( + _args: TokenStream, + input: TokenStream, +) -> TokenStream { + use quote::quote; + use syn::spanned::Spanned; + use syn::{Error, FnArg, ItemFn, PatType, parse_quote}; + + let function = parse_macro_input!(input as ItemFn); + + let mut adjusted_sig = function.sig.clone(); + adjusted_sig.output = parse_quote! { + -> ::actix_web::HttpResponse + }; + + let vis = function.vis; + let return_type = function.sig.output; + let body = function.block; + let Some(FnArg::Typed(PatType { pat: req, .. })) = + function.sig.inputs.first() + else { + return Error::new( + function.sig.inputs.span(), + "Expected first parameter to be HttpRequest", + ) + .to_compile_error() + .into(); + }; + + quote! { + #vis #adjusted_sig { + let mut handler = async || #return_type #body; + let result = handler().await; + match result { + Ok(resp) => resp, + Err(e) => e.localized_error_response(&#req), + } + } + } + .into() +} diff --git a/packages/ariadne/Cargo.toml b/packages/ariadne/Cargo.toml index 66c7e120cf..006363620c 100644 --- a/packages/ariadne/Cargo.toml +++ b/packages/ariadne/Cargo.toml @@ -14,5 +14,10 @@ either.workspace = true chrono = { workspace = true, features = ["serde"] } serde_cbor.workspace = true +ariadne-macros.workspace = true + +[features] +labrinth = ["ariadne-macros/labrinth"] + [lints] workspace = true diff --git a/packages/ariadne/src/i18n.rs b/packages/ariadne/src/i18n.rs new file mode 100644 index 0000000000..6556c65c7b --- /dev/null +++ b/packages/ariadne/src/i18n.rs @@ -0,0 +1,9 @@ +use std::borrow::Cow; + +pub use ariadne_macros::*; + +pub trait I18nEnum { + fn translation_id(&self) -> &'static str; + + fn translated_message(&self, locale: &str) -> Cow<'_, str>; +} diff --git a/packages/ariadne/src/lib.rs b/packages/ariadne/src/lib.rs index a1ee76540e..31869de03d 100644 --- a/packages/ariadne/src/lib.rs +++ b/packages/ariadne/src/lib.rs @@ -2,3 +2,4 @@ pub mod ids; pub mod networking; pub mod users; pub mod versions; +pub mod i18n; 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/labrinth-macros/src/lib.rs b/packages/labrinth-macros/src/lib.rs deleted file mode 100644 index 323cc42740..0000000000 --- a/packages/labrinth-macros/src/lib.rs +++ /dev/null @@ -1,40 +0,0 @@ -use proc_macro::TokenStream; -use quote::quote; -use syn::{FnArg, ItemFn, PatType, parse_macro_input, parse_quote}; - -#[proc_macro_attribute] -pub fn localized_api_error( - _args: TokenStream, - input: TokenStream, -) -> TokenStream { - let function = parse_macro_input!(input as ItemFn); - - let mut adjusted_sig = function.sig.clone(); - adjusted_sig.output = parse_quote! { - -> actix_web::HttpResponse - }; - - let vis = function.vis; - let return_type = function.sig.output; - let body = function.block; - let Some(FnArg::Typed(PatType { pat: req, .. })) = - function.sig.inputs.first() - else { - return quote! { - compile_error!("Expected first parameter to be HttpRequest"); - } - .into(); - }; - - quote! { - #vis #adjusted_sig { - let mut handler = async || #return_type #body; - let result = handler().await; - match result { - Ok(resp) => resp, - Err(e) => e.localized_error_response(&#req), - } - } - } - .into() -} From 50671fc2acb780ea45d117977f1b1d1d7abbe2fc Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 28 Aug 2025 19:53:54 -0500 Subject: [PATCH 03/43] Clean up I18nEnum generated code a bit --- packages/ariadne-macros/src/i18n_enum.rs | 80 +++++++++++++++--------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/packages/ariadne-macros/src/i18n_enum.rs b/packages/ariadne-macros/src/i18n_enum.rs index a348b1f029..0c26229167 100644 --- a/packages/ariadne-macros/src/i18n_enum.rs +++ b/packages/ariadne-macros/src/i18n_enum.rs @@ -1,9 +1,10 @@ use proc_macro::TokenStream; -use quote::{quote, ToTokens, TokenStreamExt}; +use quote::{format_ident, quote, quote_spanned, IdentFragment, ToTokens, TokenStreamExt}; use std::collections::HashMap; use std::mem; +use proc_macro2::Span; use syn::parse::{Parse, ParseStream}; -use syn::{parenthesized, Attribute, Data, DeriveInput, Error, Fields, Ident, LitStr, Member, Result, Token, Variant}; +use syn::{parenthesized, Attribute, Data, DeriveInput, Error, Fields, Ident, Index, LitStr, Member, Result, Token, Variant}; pub fn generate_impls(input: DeriveInput) -> Result { let enum_name = input.ident; @@ -19,7 +20,7 @@ pub fn generate_impls(input: DeriveInput) -> Result { let translation_id_cases = variants.iter().map(|variant| { let name = &variant.name; - let pattern_format = variant.pattern_format; + let pattern_format = &variant.pattern_format; let id = variant.translation_id.as_ref().unwrap(); quote! { Self::#name #pattern_format => #id, @@ -28,7 +29,7 @@ pub fn generate_impls(input: DeriveInput) -> Result { let message_cases = variants.iter().map(|variant| { let name = &variant.name; - let pattern_format = variant.pattern_format; + let pattern_format = &variant.pattern_format; let message_key = format!("{i18n_root_key}.{}", variant.translation_id.as_ref().unwrap().value()); let params = variant.translate_fields .as_ref() @@ -37,7 +38,7 @@ pub fn generate_impls(input: DeriveInput) -> Result { .iter() .map(|(param_name, field)| quote! { #param_name = #field }); quote! { - x @ Self::#name #pattern_format => + Self::#name #pattern_format => ::rust_i18n::t!(#message_key, locale = locale, #(#params),*), } }); @@ -59,7 +60,7 @@ pub fn generate_impls(input: DeriveInput) -> Result { impl ::std::fmt::Display for #enum_name { fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { - write!(f, "{}", self.translated_message("en")) + f.write_str(&self.translated_message("en")) } } } @@ -116,23 +117,24 @@ impl VariantData { translate_fields: translate_fields.unwrap_or_else(|| Ok(Default::default())), name, - pattern_format: PatternFormat::from_variant(&variant.fields), + pattern_format: PatternFormat::from_fields(variant.fields), } } } -#[derive(Copy, Clone)] enum PatternFormat { - Named, - Tuple, + Named(Vec), + Tuple(usize), Unit, } impl PatternFormat { - fn from_variant(fields: &Fields) -> Self { + fn from_fields(fields: Fields) -> Self { match fields { - Fields::Named(_) => Self::Named, - Fields::Unnamed(_) => Self::Tuple, + Fields::Named(named) => Self::Named( + named.named.into_iter().map(|x| x.ident.unwrap()).collect() + ), + Fields::Unnamed(unnamed) => Self::Tuple(unnamed.unnamed.len()), Fields::Unit => Self::Unit, } } @@ -141,8 +143,15 @@ impl PatternFormat { impl ToTokens for PatternFormat { fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { match self { - PatternFormat::Named => tokens.append_all(quote! { { .. } }), - PatternFormat::Tuple => tokens.append_all(quote! { (..) }), + PatternFormat::Named(names) => tokens.append_all(quote! { + { #(#names),* } + }), + PatternFormat::Tuple(length) => { + let names = (0..*length).map(|x| format_ident!("_{x}")); + tokens.append_all(quote! { + ( #(#names),* ) + }); + }, PatternFormat::Unit => {} } } @@ -166,21 +175,23 @@ impl Parse for TranslateFields { struct TranslateField { member: Member, translated: bool, + span: Span, } impl ToTokens for TranslateField { fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { - let member = &self.member; - let new_tokens = if self.translated { - quote! { - ::ariadne::i18n::I18nEnum::translated_message(&self.#member, locale) - } - } else { - quote! { - self.#member - } + let member = match &self.member { + Member::Named(ident) => ident.to_token_stream(), + Member::Unnamed(Index { index, span }) => format_ident!("_{index}", span = *span).to_token_stream(), }; - tokens.append_all(new_tokens); + let span = self.span; + if self.translated { + tokens.append_all(quote_spanned! {span=> + ::ariadne::i18n::I18nEnum::translated_message(&#member, locale) + }); + } else { + member.to_tokens(tokens) + } } } @@ -190,17 +201,24 @@ impl Parse for TranslateField { syn::custom_keyword!(translate); } let translated = input.peek(kw::translate); - let member = if translated { - input.parse::()?; + let member: Member; + let span: Span; + if translated { + let keyword = input.parse::()?; let content; - parenthesized!(content in input); - content.parse() + let parens = parenthesized!(content in input); + member = content.parse()?; + span = keyword.span.join(parens.span.join()) + .or_else(|| member.span()) + .unwrap(); } else { - input.parse() - }?; + member = input.parse()?; + span = member.span().unwrap(); + } Ok(Self { member, translated, + span, }) } } From 06c647a16211caa6588a6dd625da9b5787f38ea9 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 28 Aug 2025 20:35:41 -0500 Subject: [PATCH 04/43] Fix misc compile errors --- apps/labrinth/src/auth/templates/mod.rs | 30 ++++++++++++------- apps/labrinth/src/routes/internal/flows.rs | 28 ++++++++--------- apps/labrinth/src/routes/internal/session.rs | 6 ++-- apps/labrinth/src/routes/mod.rs | 21 ++++--------- .../src/routes/v3/project_creation.rs | 1 + apps/labrinth/src/search/mod.rs | 1 + packages/ariadne-macros/src/i18n_enum.rs | 4 +-- packages/ariadne/src/i18n.rs | 2 +- 8 files changed, 47 insertions(+), 46 deletions(-) diff --git a/apps/labrinth/src/auth/templates/mod.rs b/apps/labrinth/src/auth/templates/mod.rs index f4e3458784..f1279f2cac 100644 --- a/apps/labrinth/src/auth/templates/mod.rs +++ b/apps/labrinth/src/auth/templates/mod.rs @@ -1,7 +1,9 @@ use crate::auth::AuthenticationError; use actix_web::http::StatusCode; -use actix_web::{HttpResponse, ResponseError}; +use actix_web::{HttpRequest, HttpResponse, ResponseError}; use std::fmt::{Debug, Display, Formatter}; +use actix_web::http::header::{AcceptLanguage, ContentLanguage, LanguageTag, Header, QualityItem}; +use ariadne::i18n::I18nEnum; pub struct Success<'a> { pub icon: &'a str, @@ -25,6 +27,7 @@ impl Success<'_> { pub struct ErrorPage { pub code: StatusCode, pub message: String, + pub language: LanguageTag, } impl Display for ErrorPage { @@ -39,14 +42,28 @@ impl Display for ErrorPage { } impl ErrorPage { + pub fn new(error: AuthenticationError, req: &HttpRequest) -> Self { + let language = AcceptLanguage::parse(req) + .ok() + .and_then(|x| x.preference().into_item()) + .unwrap_or_else(|| LanguageTag::parse("en").unwrap()); + let message = error.translated_message(language.as_str()); + Self { + code: error.status_code(), + message: message.into_owned(), + language + } + } + pub fn render(&self) -> HttpResponse { HttpResponse::Ok() .append_header(("Content-Type", "text/html; charset=utf-8")) + .append_header(ContentLanguage(vec![QualityItem::max(self.language.to_owned())])) .body(self.to_string()) } } -impl actix_web::ResponseError for ErrorPage { +impl ResponseError for ErrorPage { fn status_code(&self) -> StatusCode { self.code } @@ -55,12 +72,3 @@ impl actix_web::ResponseError for ErrorPage { self.render() } } - -impl From for ErrorPage { - fn from(item: AuthenticationError) -> Self { - ErrorPage { - code: item.status_code(), - message: item.to_string(), - } - } -} diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 0c7a37ba21..61cd186954 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -35,6 +35,7 @@ use std::str::FromStr; use std::sync::Arc; use validator::Validate; use zxcvbn::Score; +use crate::auth::templates::ErrorPage; pub fn config(cfg: &mut ServiceConfig) { cfg.service( @@ -1109,14 +1110,13 @@ 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 @@ -1203,7 +1203,7 @@ pub async fn auth_callback( 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!( @@ -1225,9 +1225,9 @@ pub async fn auth_callback( } else { Err::(AuthenticationError::InvalidCredentials) } - }.await; + }; - Ok(res?) + res().await.map_err(|e| ErrorPage::new(e, &req)) } #[derive(Deserialize)] @@ -1434,7 +1434,7 @@ 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 flow = DBFlow::ConfirmEmail { @@ -1520,7 +1520,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?; @@ -1650,7 +1650,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?; diff --git a/apps/labrinth/src/routes/internal/session.rs b/apps/labrinth/src/routes/internal/session.rs index 1b20e3aee9..d8dc403584 100644 --- a/apps/labrinth/src/routes/internal/session.rs +++ b/apps/labrinth/src/routes/internal/session.rs @@ -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/mod.rs b/apps/labrinth/src/routes/mod.rs index 76b7b1ed5b..4fc7fef1c7 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -5,7 +5,6 @@ use crate::util::env::parse_strings_from_var; use actix_cors::Cors; use actix_files::Files; use actix_web::http::StatusCode; -use actix_web::http::header::Header; use actix_web::{HttpResponse, ResponseError, web}; use futures::FutureExt; use ariadne::i18n::I18nEnum; @@ -241,24 +240,16 @@ macro_rules! labrinth_error_type { ($error_enum:ty) => { impl $error_enum { pub fn as_api_error<'a>(&self) -> crate::models::error::ApiError<'a> { - self.as_api_error_with_description(self.to_string().into()) + self.as_localized_api_error("en") } pub fn as_localized_api_error<'a>( &self, - language: &actix_web::http::header::LanguageTag, - ) -> crate::models::error::ApiError<'a> { - let description = self.translated_message(language.as_str()); - self.as_api_error_with_description(description) - } - - fn as_api_error_with_description<'a>( - &self, - description: std::borrow::Cow<'a, str>, + language: &str, ) -> crate::models::error::ApiError<'a> { crate::models::error::ApiError { error: self.translation_id(), - description, + description: self.translated_message(language), } } @@ -269,11 +260,11 @@ macro_rules! labrinth_error_type { let language = AcceptLanguage::parse(req) .ok() .and_then(|x| x.preference().into_item()) - .unwrap_or_else(|| LanguageTag::parse("en-US").unwrap()); - let body = self.as_localized_api_error(&language); + .unwrap_or_else(|| LanguageTag::parse("en").unwrap()); + let body = self.as_localized_api_error(language.as_str()); actix_web::HttpResponse::build(self.status_code()) - .insert_header(ContentLanguage(vec![QualityItem::max(language)])) + .append_header(ContentLanguage(vec![QualityItem::max(language)])) .json(body) } } diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index cc5c89b1e6..a283acda82 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -44,6 +44,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.route("project", web::post().to(project_create)); } +// TODO: Migrate to I18nEnum #[derive(Error, Debug)] pub enum CreateError { #[error("Environment Error")] diff --git a/apps/labrinth/src/search/mod.rs b/apps/labrinth/src/search/mod.rs index 663e1a83fa..fe7e208216 100644 --- a/apps/labrinth/src/search/mod.rs +++ b/apps/labrinth/src/search/mod.rs @@ -14,6 +14,7 @@ use thiserror::Error; pub mod indexing; +// TODO: Migrate to I18nEnum #[derive(Error, Debug)] pub enum SearchError { #[error("MeiliSearch Error: {0}")] diff --git a/packages/ariadne-macros/src/i18n_enum.rs b/packages/ariadne-macros/src/i18n_enum.rs index 0c26229167..9c0ee283a2 100644 --- a/packages/ariadne-macros/src/i18n_enum.rs +++ b/packages/ariadne-macros/src/i18n_enum.rs @@ -51,7 +51,7 @@ pub fn generate_impls(input: DeriveInput) -> Result { } } - fn translated_message(&self, locale: &str) -> ::std::borrow::Cow<'_, str> { + fn translated_message<'a>(&self, locale: &str) -> ::std::borrow::Cow<'a, str> { match self { #(#message_cases)* } @@ -187,7 +187,7 @@ impl ToTokens for TranslateField { let span = self.span; if self.translated { tokens.append_all(quote_spanned! {span=> - ::ariadne::i18n::I18nEnum::translated_message(&#member, locale) + ::ariadne::i18n::I18nEnum::translated_message(#member, locale) }); } else { member.to_tokens(tokens) diff --git a/packages/ariadne/src/i18n.rs b/packages/ariadne/src/i18n.rs index 6556c65c7b..f622b238f4 100644 --- a/packages/ariadne/src/i18n.rs +++ b/packages/ariadne/src/i18n.rs @@ -5,5 +5,5 @@ pub use ariadne_macros::*; pub trait I18nEnum { fn translation_id(&self) -> &'static str; - fn translated_message(&self, locale: &str) -> Cow<'_, str>; + fn translated_message<'a>(&self, locale: &str) -> Cow<'a, str>; } From 25b8452cb2a138beb37cc0281f4aeac94dfdd8b7 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 28 Aug 2025 20:37:35 -0500 Subject: [PATCH 05/43] Cargo fmt --- apps/labrinth/src/auth/mod.rs | 6 +- apps/labrinth/src/auth/templates/mod.rs | 12 ++- apps/labrinth/src/file_hosting/mod.rs | 5 +- apps/labrinth/src/models/error.rs | 2 +- apps/labrinth/src/routes/internal/flows.rs | 60 ++++++++++---- apps/labrinth/src/routes/maven.rs | 2 +- apps/labrinth/src/routes/mod.rs | 36 ++++++--- apps/labrinth/src/routes/not_found.rs | 2 +- apps/labrinth/src/routes/v3/friends.rs | 2 +- packages/ariadne-macros/src/i18n_enum.rs | 93 ++++++++++++++-------- packages/ariadne/src/lib.rs | 2 +- 11 files changed, 148 insertions(+), 74 deletions(-) diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs index ea72c47a05..060b347053 100644 --- a/apps/labrinth/src/auth/mod.rs +++ b/apps/labrinth/src/auth/mod.rs @@ -14,11 +14,11 @@ use serde::{Deserialize, Serialize}; pub use validate::{check_is_moderator_from_headers, get_user_from_headers}; use crate::file_hosting::FileHostingError; -use actix_web::{HttpResponse, ResponseError}; +use crate::labrinth_error_type; use actix_web::http::StatusCode; -use thiserror::Error; +use actix_web::{HttpResponse, ResponseError}; use ariadne::i18n::I18nEnum; -use crate::labrinth_error_type; +use thiserror::Error; // TODO add fields #[derive(Error, I18nEnum, Debug)] diff --git a/apps/labrinth/src/auth/templates/mod.rs b/apps/labrinth/src/auth/templates/mod.rs index f1279f2cac..6a796ab58b 100644 --- a/apps/labrinth/src/auth/templates/mod.rs +++ b/apps/labrinth/src/auth/templates/mod.rs @@ -1,9 +1,11 @@ use crate::auth::AuthenticationError; use actix_web::http::StatusCode; +use actix_web::http::header::{ + AcceptLanguage, ContentLanguage, Header, LanguageTag, QualityItem, +}; use actix_web::{HttpRequest, HttpResponse, ResponseError}; -use std::fmt::{Debug, Display, Formatter}; -use actix_web::http::header::{AcceptLanguage, ContentLanguage, LanguageTag, Header, QualityItem}; use ariadne::i18n::I18nEnum; +use std::fmt::{Debug, Display, Formatter}; pub struct Success<'a> { pub icon: &'a str, @@ -51,14 +53,16 @@ impl ErrorPage { Self { code: error.status_code(), message: message.into_owned(), - language + language, } } pub fn render(&self) -> HttpResponse { HttpResponse::Ok() .append_header(("Content-Type", "text/html; charset=utf-8")) - .append_header(ContentLanguage(vec![QualityItem::max(self.language.to_owned())])) + .append_header(ContentLanguage(vec![QualityItem::max( + self.language.to_owned(), + )])) .body(self.to_string()) } } diff --git a/apps/labrinth/src/file_hosting/mod.rs b/apps/labrinth/src/file_hosting/mod.rs index 548b40ac5b..134334153b 100644 --- a/apps/labrinth/src/file_hosting/mod.rs +++ b/apps/labrinth/src/file_hosting/mod.rs @@ -4,8 +4,8 @@ use thiserror::Error; mod mock; mod s3_host; -use bytes::Bytes; use ariadne::i18n::I18nEnum; +use bytes::Bytes; pub use mock::MockHost; pub use s3_host::{S3BucketConfig, S3Host}; @@ -13,7 +13,8 @@ pub use s3_host::{S3BucketConfig, S3Host}; #[i18n_root_key("error.file_hosting_error")] pub enum FileHostingError { #[translation_id("s3")] - #[translate_fields(action = 0, cause = 1)] // TODO: Use an I18nEnum instead of a String + #[translate_fields(action = 0, cause = 1)] + // TODO: Use an I18nEnum instead of a String // #[error("S3 error when {0}: {1}")] S3Error(&'static str, s3::error::S3Error), diff --git a/apps/labrinth/src/models/error.rs b/apps/labrinth/src/models/error.rs index 2c50c1411b..72f317f610 100644 --- a/apps/labrinth/src/models/error.rs +++ b/apps/labrinth/src/models/error.rs @@ -1,5 +1,5 @@ -use std::borrow::Cow; use serde::{Deserialize, Serialize}; +use std::borrow::Cow; /// An error returned by the API #[derive(Serialize, Deserialize)] diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 61cd186954..2e244edf98 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -1,4 +1,5 @@ use crate::auth::email::send_email; +use crate::auth::templates::ErrorPage; use crate::auth::validate::{ get_full_user_from_headers, get_user_record_from_bearer_token, }; @@ -35,7 +36,6 @@ use std::str::FromStr; use std::sync::Arc; use validator::Validate; use zxcvbn::Score; -use crate::auth::templates::ErrorPage; pub fn config(cfg: &mut ServiceConfig) { cfg.service( @@ -1121,17 +1121,18 @@ pub async fn auth_callback( // 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 { @@ -1143,9 +1144,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,23 +1167,32 @@ pub async fn auth_callback( send_email( email, "Authentication method added", - &format!("When logging into Modrinth, you can now log in using the {} authentication provider.", provider.as_str()), + &format!( + "When logging into Modrinth, you can now log in using the {} authentication provider.", + provider.as_str() + ), "If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).", None, )?; } 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 } @@ -1200,10 +1213,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!( @@ -1223,7 +1246,9 @@ pub async fn auth_callback( .json(serde_json::json!({ "url": redirect_url }))) } } else { - Err::(AuthenticationError::InvalidCredentials) + Err::( + AuthenticationError::InvalidCredentials, + ) } }; @@ -1434,7 +1459,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 flow = DBFlow::ConfirmEmail { diff --git a/apps/labrinth/src/routes/maven.rs b/apps/labrinth/src/routes/maven.rs index ed822af0a0..fb724fbf97 100644 --- a/apps/labrinth/src/routes/maven.rs +++ b/apps/labrinth/src/routes/maven.rs @@ -12,10 +12,10 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::{auth::get_user_from_headers, database}; use actix_web::{HttpRequest, HttpResponse, get, route, web}; +use ariadne::i18n::localized_labrinth_error; use sqlx::PgPool; use std::collections::HashSet; use yaserde::YaSerialize; -use ariadne::i18n::localized_labrinth_error; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(maven_metadata); diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 4fc7fef1c7..e63152529a 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -6,8 +6,8 @@ use actix_cors::Cors; use actix_files::Files; use actix_web::http::StatusCode; use actix_web::{HttpResponse, ResponseError, web}; -use futures::FutureExt; use ariadne::i18n::I18nEnum; +use futures::FutureExt; pub mod internal; pub mod v2; @@ -119,7 +119,8 @@ pub enum ApiError { Clickhouse(#[from] clickhouse::error::Error), #[translation_id("xml_error")] - #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String + #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String // #[error("Internal server error: {0}")] Xml(String), @@ -134,12 +135,14 @@ pub enum ApiError { Authentication(#[from] crate::auth::AuthenticationError), #[translation_id("unauthorized")] - #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String + #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String // #[error("Authentication Error: {0}")] CustomAuthentication(String), #[translation_id("invalid_input")] - #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String + #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String // #[error("Invalid Input: {0}")] InvalidInput(String), @@ -160,12 +163,14 @@ pub enum ApiError { Indexing(#[from] crate::search::indexing::IndexingError), #[translation_id("payments_error")] - #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String + #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String // #[error("Payments Error: {0}")] Payments(String), #[translation_id("discord_error")] - #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String + #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String // #[error("Discord Error: {0}")] Discord(String), @@ -214,7 +219,8 @@ pub enum ApiError { NotFound, #[translation_id("conflict")] - #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String + #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String // #[error("Conflict: {0}")] Conflict(String), @@ -239,7 +245,9 @@ pub enum ApiError { macro_rules! labrinth_error_type { ($error_enum:ty) => { impl $error_enum { - pub fn as_api_error<'a>(&self) -> crate::models::error::ApiError<'a> { + pub fn as_api_error<'a>( + &self, + ) -> crate::models::error::ApiError<'a> { self.as_localized_api_error("en") } @@ -253,9 +261,13 @@ macro_rules! labrinth_error_type { } } - pub fn localized_error_response(&self, req: &actix_web::HttpRequest) -> actix_web::HttpResponse { + pub fn localized_error_response( + &self, + req: &actix_web::HttpRequest, + ) -> actix_web::HttpResponse { use actix_web::http::header::{ - AcceptLanguage, ContentLanguage, Header, LanguageTag, QualityItem, + AcceptLanguage, ContentLanguage, Header, LanguageTag, + QualityItem, }; let language = AcceptLanguage::parse(req) .ok() @@ -264,7 +276,9 @@ macro_rules! labrinth_error_type { let body = self.as_localized_api_error(language.as_str()); actix_web::HttpResponse::build(self.status_code()) - .append_header(ContentLanguage(vec![QualityItem::max(language)])) + .append_header(ContentLanguage(vec![QualityItem::max( + language, + )])) .json(body) } } diff --git a/apps/labrinth/src/routes/not_found.rs b/apps/labrinth/src/routes/not_found.rs index 55ab4277e6..bdfcc2fef0 100644 --- a/apps/labrinth/src/routes/not_found.rs +++ b/apps/labrinth/src/routes/not_found.rs @@ -1,5 +1,5 @@ -use actix_web::{HttpRequest, HttpResponse}; use crate::routes::ApiError; +use actix_web::{HttpRequest, HttpResponse}; pub async fn not_found(req: HttpRequest) -> HttpResponse { ApiError::NotFound.localized_error_response(&req) diff --git a/apps/labrinth/src/routes/v3/friends.rs b/apps/labrinth/src/routes/v3/friends.rs index 5914a7ec42..8ae42a951d 100644 --- a/apps/labrinth/src/routes/v3/friends.rs +++ b/apps/labrinth/src/routes/v3/friends.rs @@ -12,10 +12,10 @@ use crate::routes::internal::statuses::{ use crate::sync::friends::RedisFriendsMessage; use crate::sync::status::get_user_status; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; +use ariadne::i18n::localized_labrinth_error; use ariadne::networking::message::ServerToClientMessage; use chrono::Utc; use sqlx::PgPool; -use ariadne::i18n::localized_labrinth_error; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(add_friend); diff --git a/packages/ariadne-macros/src/i18n_enum.rs b/packages/ariadne-macros/src/i18n_enum.rs index 9c0ee283a2..b3ce984be9 100644 --- a/packages/ariadne-macros/src/i18n_enum.rs +++ b/packages/ariadne-macros/src/i18n_enum.rs @@ -1,10 +1,15 @@ use proc_macro::TokenStream; -use quote::{format_ident, quote, quote_spanned, IdentFragment, ToTokens, TokenStreamExt}; +use proc_macro2::Span; +use quote::{ + IdentFragment, ToTokens, TokenStreamExt, format_ident, quote, quote_spanned, +}; use std::collections::HashMap; use std::mem; -use proc_macro2::Span; use syn::parse::{Parse, ParseStream}; -use syn::{parenthesized, Attribute, Data, DeriveInput, Error, Fields, Ident, Index, LitStr, Member, Result, Token, Variant}; +use syn::{ + Attribute, Data, DeriveInput, Error, Fields, Ident, Index, LitStr, Member, + Result, Token, Variant, parenthesized, +}; pub fn generate_impls(input: DeriveInput) -> Result { let enum_name = input.ident; @@ -30,8 +35,12 @@ pub fn generate_impls(input: DeriveInput) -> Result { let message_cases = variants.iter().map(|variant| { let name = &variant.name; let pattern_format = &variant.pattern_format; - let message_key = format!("{i18n_root_key}.{}", variant.translation_id.as_ref().unwrap().value()); - let params = variant.translate_fields + let message_key = format!( + "{i18n_root_key}.{}", + variant.translation_id.as_ref().unwrap().value() + ); + let params = variant + .translate_fields .as_ref() .unwrap() .0 @@ -69,22 +78,26 @@ pub fn generate_impls(input: DeriveInput) -> Result { Ok(result) } -fn find_i18n_root_key(enum_name: &Ident, attrs: Vec) -> Result { +fn find_i18n_root_key( + enum_name: &Ident, + attrs: Vec, +) -> Result { for attr in attrs { if attr.path().is_ident("i18n_root_key") { let key: LitStr = attr.parse_args()?; return Ok(key.value()); } } - Err(Error::new(enum_name.span(), "Missing #[i18n_root_key] attribute")) + Err(Error::new( + enum_name.span(), + "Missing #[i18n_root_key] attribute", + )) } fn parse_variants( variants: impl IntoIterator, ) -> Result> { - let mut result = variants.into_iter() - .map(VariantData::parse) - .collect(); + let mut result = variants.into_iter().map(VariantData::parse).collect(); validate_variants(&mut result)?; Ok(result) } @@ -106,15 +119,17 @@ impl VariantData { translation_id = Some(attr.parse_args()); } else if attr.path().is_ident("translate_fields") { translate_fields = Some(attr.parse_args()); - } } Self { - translation_id: translation_id.unwrap_or_else(|| Err(Error::new( - name.span(), - format!("Missing #[translation_id] for variant {}", name), - ))), - translate_fields: translate_fields.unwrap_or_else(|| Ok(Default::default())), + translation_id: translation_id.unwrap_or_else(|| { + Err(Error::new( + name.span(), + format!("Missing #[translation_id] for variant {}", name), + )) + }), + translate_fields: translate_fields + .unwrap_or_else(|| Ok(Default::default())), name, pattern_format: PatternFormat::from_fields(variant.fields), @@ -132,7 +147,7 @@ impl PatternFormat { fn from_fields(fields: Fields) -> Self { match fields { Fields::Named(named) => Self::Named( - named.named.into_iter().map(|x| x.ident.unwrap()).collect() + named.named.into_iter().map(|x| x.ident.unwrap()).collect(), ), Fields::Unnamed(unnamed) => Self::Tuple(unnamed.unnamed.len()), Fields::Unit => Self::Unit, @@ -151,7 +166,7 @@ impl ToTokens for PatternFormat { tokens.append_all(quote! { ( #(#names),* ) }); - }, + } PatternFormat::Unit => {} } } @@ -162,12 +177,15 @@ struct TranslateFields(HashMap); impl Parse for TranslateFields { fn parse(input: ParseStream) -> Result { - let result = input.parse_terminated(|input| { - let key = input.parse::()?; - input.parse::()?; - let value = input.parse::()?; - Ok((key, value)) - }, Token![,])?; + let result = input.parse_terminated( + |input| { + let key = input.parse::()?; + input.parse::()?; + let value = input.parse::()?; + Ok((key, value)) + }, + Token![,], + )?; Ok(Self(result.into_iter().collect())) } } @@ -182,7 +200,9 @@ impl ToTokens for TranslateField { fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { let member = match &self.member { Member::Named(ident) => ident.to_token_stream(), - Member::Unnamed(Index { index, span }) => format_ident!("_{index}", span = *span).to_token_stream(), + Member::Unnamed(Index { index, span }) => { + format_ident!("_{index}", span = *span).to_token_stream() + } }; let span = self.span; if self.translated { @@ -208,7 +228,9 @@ impl Parse for TranslateField { let content; let parens = parenthesized!(content in input); member = content.parse()?; - span = keyword.span.join(parens.span.join()) + span = keyword + .span + .join(parens.span.join()) .or_else(|| member.span()) .unwrap(); } else { @@ -224,19 +246,26 @@ impl Parse for TranslateField { } fn validate_variants(variants: &mut Vec) -> Result<()> { - fn take_err(variant_name: &Ident, value: &mut Result) -> Option { + fn take_err( + variant_name: &Ident, + value: &mut Result, + ) -> Option { match value { Ok(_) => None, - Err(e) => Some(mem::replace(e, Error::new(variant_name.span(), ""))), + Err(e) => { + Some(mem::replace(e, Error::new(variant_name.span(), ""))) + } } } variants .iter_mut() - .flat_map(|x| vec![ - take_err(&x.name, &mut x.translation_id), - take_err(&x.name, &mut x.translate_fields), - ]) + .flat_map(|x| { + vec![ + take_err(&x.name, &mut x.translation_id), + take_err(&x.name, &mut x.translate_fields), + ] + }) .filter_map(|x| x) .reduce(|mut a, b| { a.extend(b); diff --git a/packages/ariadne/src/lib.rs b/packages/ariadne/src/lib.rs index 31869de03d..9335e035df 100644 --- a/packages/ariadne/src/lib.rs +++ b/packages/ariadne/src/lib.rs @@ -1,5 +1,5 @@ +pub mod i18n; pub mod ids; pub mod networking; pub mod users; pub mod versions; -pub mod i18n; From 3a83373695dbbe889decb623f0b94851db656d1f Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Tue, 2 Sep 2025 14:57:38 -0500 Subject: [PATCH 06/43] Migrate project_creation::CreateError over to I18nError --- apps/labrinth/src/routes/mod.rs | 34 +++-- .../src/routes/v3/project_creation.rs | 132 +++++++++++------- packages/ariadne-macros/src/i18n_enum.rs | 2 +- 3 files changed, 107 insertions(+), 61 deletions(-) diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index e63152529a..664189b415 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -5,7 +5,8 @@ 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, ResponseError, web}; +use actix_web::http::header::{AcceptLanguage, Header, LanguageTag}; +use actix_web::{HttpRequest, HttpResponse, ResponseError, web}; use ariadne::i18n::I18nEnum; use futures::FutureExt; @@ -247,7 +248,7 @@ macro_rules! labrinth_error_type { impl $error_enum { pub fn as_api_error<'a>( &self, - ) -> crate::models::error::ApiError<'a> { + ) -> $crate::models::error::ApiError<'a> { self.as_localized_api_error("en") } @@ -255,8 +256,8 @@ macro_rules! labrinth_error_type { &self, language: &str, ) -> crate::models::error::ApiError<'a> { - crate::models::error::ApiError { - error: self.translation_id(), + $crate::models::error::ApiError { + error: $crate::routes::error_id_for_error(self), description: self.translated_message(language), } } @@ -265,14 +266,9 @@ macro_rules! labrinth_error_type { &self, req: &actix_web::HttpRequest, ) -> actix_web::HttpResponse { - use actix_web::http::header::{ - AcceptLanguage, ContentLanguage, Header, LanguageTag, - QualityItem, - }; - let language = AcceptLanguage::parse(req) - .ok() - .and_then(|x| x.preference().into_item()) - .unwrap_or_else(|| LanguageTag::parse("en").unwrap()); + use actix_web::http::header::{ContentLanguage, QualityItem}; + + let language = $crate::routes::parse_accept_language(req); let body = self.as_localized_api_error(language.as_str()); actix_web::HttpResponse::build(self.status_code()) @@ -326,3 +322,17 @@ impl ResponseError for ApiError { HttpResponse::build(self.status_code()).json(self.as_api_error()) } } + +pub fn parse_accept_language(req: &HttpRequest) -> LanguageTag { + AcceptLanguage::parse(req) + .ok() + .and_then(|x| x.preference().into_item()) + .unwrap_or_else(|| LanguageTag::parse("en").unwrap()) +} + +pub fn error_id_for_error(error: &impl I18nEnum) -> &'static str { + let translation_id = error.translation_id(); + translation_id + .split_once('.') + .map_or(translation_id, |(base, _)| base) +} diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index a283acda82..69691df59b 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -7,7 +7,7 @@ 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::labrinth_error_type; use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; @@ -26,6 +26,7 @@ use actix_multipart::{Field, Multipart}; use actix_web::http::StatusCode; use actix_web::web::{self, Data}; use actix_web::{HttpRequest, HttpResponse}; +use ariadne::i18n::I18nEnum; use ariadne::ids::UserId; use ariadne::ids::base62_impl::to_base62; use chrono::Utc; @@ -44,51 +45,110 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.route("project", web::post().to(project_create)); } -// TODO: Migrate to I18nEnum -#[derive(Error, Debug)] +#[derive(Error, I18nEnum, Debug)] +#[i18n_root_key("error.project_creation")] pub enum CreateError { - #[error("Environment Error")] + #[translation_id("environment_error")] + // #[error("Environment Error")] EnvError(#[from] dotenvy::Error), - #[error("An unknown database error occurred")] + + #[translation_id("database_error.unknown")] + // #[error("An unknown database error occurred")] SqlxDatabaseError(#[from] sqlx::Error), - #[error("Database Error: {0}")] + + #[translation_id("database_error")] + #[translate_fields(cause = 0)] + // #[error("Database Error: {0}")] DatabaseError(#[from] models::DatabaseError), - #[error("Indexing Error: {0}")] + + #[translation_id("indexing_error")] + #[translate_fields(cause = 0)] + // #[error("Indexing Error: {0}")] IndexingError(#[from] IndexingError), - #[error("Error while parsing multipart payload: {0}")] + + #[translation_id("invalid_input.multipart")] + #[translate_fields(cause = 0)] + // #[error("Error while parsing multipart payload: {0}")] MultipartError(#[from] actix_multipart::MultipartError), - #[error("Error while parsing JSON: {0}")] + + #[translation_id("invalid_input.parsing")] + #[translate_fields(cause = 0)] + // #[error("Error while parsing JSON: {0}")] SerDeError(#[from] serde_json::Error), - #[error("Error while validating input: {0}")] + + #[translation_id("invalid_input.validation")] + #[translate_fields(cause = 0)] + // #[error("Error while validating input: {0}")] ValidationError(String), - #[error("Error while uploading file: {0}")] + + #[translation_id("file_hosting_error")] + #[translate_fields(cause = translate(0))] + // #[error("Error while uploading file: {0}")] FileHostingError(#[from] FileHostingError), - #[error("Error while validating uploaded file: {0}")] + + #[translation_id("invalid_input.file")] + #[translate_fields(cause = translate(0))] + // #[error("Error while validating uploaded file: {0}")] FileValidationError(#[from] crate::validate::ValidationError), - #[error("{}", .0)] + + #[translation_id("invalid_input.missing_value")] + #[translate_fields(cause = 0)] + // #[error("{}", .0)] MissingValueError(String), - #[error("Invalid format for image: {0}")] + + #[translation_id("invalid_input.icon")] + #[translate_fields(cause = 0)] + // #[error("Invalid format for image: {0}")] InvalidIconFormat(String), - #[error("Error with multipart data: {0}")] + + #[translation_id("invalid_input")] + #[translate_fields(cause = 0)] + // #[error("Error with multipart data: {0}")] InvalidInput(String), - #[error("Invalid game version: {0}")] + + #[translation_id("invalid_input.game_version")] + #[translate_fields(cause = 0)] + // #[error("Invalid game version: {0}")] InvalidGameVersion(String), - #[error("Invalid loader: {0}")] + + #[translation_id("invalid_input.loader")] + #[translate_fields(cause = 0)] + // #[error("Invalid loader: {0}")] InvalidLoader(String), - #[error("Invalid category: {0}")] + + #[translation_id("invalid_input.category")] + #[translate_fields(cause = 0)] + // #[error("Invalid category: {0}")] InvalidCategory(String), - #[error("Invalid file type for version file: {0}")] + + #[translation_id("invalid_input.file_type")] + #[translate_fields(cause = 0)] + // #[error("Invalid file type for version file: {0}")] InvalidFileType(String), - #[error("Slug is already taken!")] + + #[translation_id("invalid_input.slug_collision")] + // #[error("Slug is already taken!")] SlugCollision, - #[error("Authentication Error: {0}")] + + #[translation_id("unauthorized")] + #[translate_fields(cause = translate(0))] + // #[error("Authentication Error: {0}")] Unauthorized(#[from] AuthenticationError), - #[error("Authentication Error: {0}")] + + #[translation_id("unauthorized")] + #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String + // #[error("Authentication Error: {0}")] CustomAuthenticationError(String), - #[error("Image Parsing Error: {0}")] + + #[translation_id("invalid_image")] + #[translate_fields(cause = 0)] + // #[error("Image Parsing Error: {0}")] ImageError(#[from] ImageError), } +labrinth_error_type!(CreateError); + impl actix_web::ResponseError for CreateError { fn status_code(&self) -> StatusCode { match self { @@ -122,31 +182,7 @@ impl actix_web::ResponseError for CreateError { } 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(), - }) + HttpResponse::build(self.status_code()).json(self.as_api_error()) } } diff --git a/packages/ariadne-macros/src/i18n_enum.rs b/packages/ariadne-macros/src/i18n_enum.rs index b3ce984be9..96b74f29f1 100644 --- a/packages/ariadne-macros/src/i18n_enum.rs +++ b/packages/ariadne-macros/src/i18n_enum.rs @@ -48,7 +48,7 @@ pub fn generate_impls(input: DeriveInput) -> Result { .map(|(param_name, field)| quote! { #param_name = #field }); quote! { Self::#name #pattern_format => - ::rust_i18n::t!(#message_key, locale = locale, #(#params),*), + ::rust_i18n::t!(#message_key, locale = locale #(, #params)*), } }); From e0015d531dcaf87a95d9437b1b9ab7e060c01684 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Tue, 2 Sep 2025 15:03:29 -0500 Subject: [PATCH 07/43] Move labrinth error stuff to its own module --- apps/labrinth/src/auth/checks.rs | 2 +- apps/labrinth/src/clickhouse/fetch.rs | 7 +- apps/labrinth/src/lib.rs | 9 +- apps/labrinth/src/queue/analytics.rs | 2 +- apps/labrinth/src/queue/moderation.rs | 2 +- apps/labrinth/src/queue/payouts.rs | 2 +- apps/labrinth/src/routes/analytics.rs | 2 +- apps/labrinth/src/routes/debug/mod.rs | 2 +- apps/labrinth/src/routes/error.rs | 257 ++++++++++++++++++ apps/labrinth/src/routes/internal/admin.rs | 2 +- apps/labrinth/src/routes/internal/billing.rs | 2 +- apps/labrinth/src/routes/internal/flows.rs | 2 +- apps/labrinth/src/routes/internal/gdpr.rs | 2 +- apps/labrinth/src/routes/internal/medal.rs | 2 +- apps/labrinth/src/routes/internal/mod.rs | 2 +- .../src/routes/internal/moderation.rs | 2 +- apps/labrinth/src/routes/internal/pats.rs | 2 +- apps/labrinth/src/routes/internal/session.rs | 2 +- apps/labrinth/src/routes/internal/statuses.rs | 2 +- apps/labrinth/src/routes/maven.rs | 2 +- apps/labrinth/src/routes/mod.rs | 251 +---------------- apps/labrinth/src/routes/not_found.rs | 2 +- apps/labrinth/src/routes/updates.rs | 2 +- apps/labrinth/src/routes/v2/mod.rs | 2 +- apps/labrinth/src/routes/v2/moderation.rs | 2 +- apps/labrinth/src/routes/v2/notifications.rs | 2 +- apps/labrinth/src/routes/v2/projects.rs | 3 +- apps/labrinth/src/routes/v2/reports.rs | 3 +- apps/labrinth/src/routes/v2/statistics.rs | 3 +- apps/labrinth/src/routes/v2/tags.rs | 2 +- apps/labrinth/src/routes/v2/teams.rs | 3 +- apps/labrinth/src/routes/v2/threads.rs | 3 +- apps/labrinth/src/routes/v2/users.rs | 3 +- apps/labrinth/src/routes/v2/version_file.rs | 2 +- apps/labrinth/src/routes/v2/versions.rs | 2 +- apps/labrinth/src/routes/v2_reroute.rs | 2 +- apps/labrinth/src/routes/v3/analytics_get.rs | 2 +- apps/labrinth/src/routes/v3/collections.rs | 2 +- apps/labrinth/src/routes/v3/friends.rs | 2 +- apps/labrinth/src/routes/v3/images.rs | 2 +- apps/labrinth/src/routes/v3/mod.rs | 2 +- apps/labrinth/src/routes/v3/notifications.rs | 2 +- apps/labrinth/src/routes/v3/oauth_clients.rs | 2 +- apps/labrinth/src/routes/v3/organizations.rs | 2 +- apps/labrinth/src/routes/v3/payouts.rs | 2 +- apps/labrinth/src/routes/v3/projects.rs | 2 +- apps/labrinth/src/routes/v3/reports.rs | 2 +- .../v3/shared_instance_version_creation.rs | 2 +- .../src/routes/v3/shared_instances.rs | 2 +- apps/labrinth/src/routes/v3/statistics.rs | 2 +- apps/labrinth/src/routes/v3/tags.rs | 2 +- apps/labrinth/src/routes/v3/teams.rs | 2 +- apps/labrinth/src/routes/v3/threads.rs | 2 +- apps/labrinth/src/routes/v3/users.rs | 3 +- apps/labrinth/src/routes/v3/version_file.rs | 2 +- apps/labrinth/src/routes/v3/versions.rs | 2 +- apps/labrinth/src/util/archon.rs | 2 +- apps/labrinth/src/util/avalara1099.rs | 2 +- apps/labrinth/src/util/captcha.rs | 2 +- apps/labrinth/src/util/img.rs | 2 +- apps/labrinth/src/util/ratelimit.rs | 2 +- apps/labrinth/src/util/routes.rs | 2 +- apps/labrinth/src/util/webhook.rs | 2 +- 63 files changed, 333 insertions(+), 316 deletions(-) create mode 100644 apps/labrinth/src/routes/error.rs diff --git a/apps/labrinth/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs index f810ca773c..334460f9f9 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; use itertools::Itertools; use sqlx::PgPool; 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/lib.rs b/apps/labrinth/src/lib.rs index fa14d55b44..f7b274ee74 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 rust_i18n::i18n; use util::cors::default_cors; @@ -309,16 +310,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/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/moderation.rs b/apps/labrinth/src/queue/moderation.rs index 87ebce31aa..8c5967d04f 100644 --- a/apps/labrinth/src/queue/moderation.rs +++ b/apps/labrinth/src/queue/moderation.rs @@ -8,7 +8,7 @@ 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; diff --git a/apps/labrinth/src/queue/payouts.rs b/apps/labrinth/src/queue/payouts.rs index ccdeb678a0..34a70737dd 100644 --- a/apps/labrinth/src/queue/payouts.rs +++ b/apps/labrinth/src/queue/payouts.rs @@ -3,7 +3,7 @@ use crate::models::payouts::{ PayoutMethodType, }; use crate::models::projects::MonetizationStatus; -use crate::routes::ApiError; +use crate::routes::error::ApiError; use base64::Engine; use chrono::{DateTime, Datelike, Duration, NaiveTime, TimeZone, Utc}; use dashmap::DashMap; diff --git a/apps/labrinth/src/routes/analytics.rs b/apps/labrinth/src/routes/analytics.rs index 5f4fd5a5a3..275357cf9b 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..9c13f40b05 --- /dev/null +++ b/apps/labrinth/src/routes/error.rs @@ -0,0 +1,257 @@ +use crate::file_hosting::FileHostingError; +use actix_http::StatusCode; +use actix_http::header::{Header, LanguageTag}; +use actix_web::http::header::AcceptLanguage; +use actix_web::{HttpRequest, HttpResponse, ResponseError}; +use ariadne::i18n::I18nEnum; + +#[derive(thiserror::Error, I18nEnum, Debug)] +#[i18n_root_key("error")] +pub enum ApiError { + #[translation_id("environment_error")] + // #[error("Environment Error")] + Env(#[from] dotenvy::Error), + + #[translation_id("file_hosting_error")] + #[translate_fields(cause = translate(0))] + // #[error("Error while uploading file: {0}")] + FileHosting(#[from] FileHostingError), + + #[translation_id("database_error")] + #[translate_fields(cause = translate(0))] + // #[error("Database Error: {0}")] + Database(#[from] crate::database::models::DatabaseError), + + #[translation_id("database_error")] + #[translate_fields(cause = 0)] + // #[error("Database Error: {0}")] + SqlxDatabase(#[from] sqlx::Error), + + #[translation_id("database_error")] + #[translate_fields(cause = 0)] + // #[error("Database Error: {0}")] + RedisDatabase(#[from] redis::RedisError), + + #[translation_id("clickhouse_error")] + #[translate_fields(cause = 0)] + // #[error("Clickhouse Error: {0}")] + Clickhouse(#[from] clickhouse::error::Error), + + #[translation_id("xml_error")] + #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String + // #[error("Internal server error: {0}")] + Xml(String), + + #[translation_id("json_error")] + #[translate_fields(cause = 0)] + // #[error("Deserialization error: {0}")] + Json(#[from] serde_json::Error), + + #[translation_id("unauthorized")] + #[translate_fields(cause = translate(0))] + // #[error("Authentication Error: {0}")] + Authentication(#[from] crate::auth::AuthenticationError), + + #[translation_id("unauthorized")] + #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String + // #[error("Authentication Error: {0}")] + CustomAuthentication(String), + + #[translation_id("invalid_input")] + #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String + // #[error("Invalid Input: {0}")] + InvalidInput(String), + + // TODO: Perhaps remove this in favor of InvalidInput? + #[translation_id("invalid_input")] + #[translate_fields(cause = 0)] + // #[error("Error while validating input: {0}")] + Validation(String), + + #[translation_id("search_error")] + #[translate_fields(cause = 0)] + // #[error("Search Error: {0}")] + Search(#[from] meilisearch_sdk::errors::Error), + + #[translation_id("indexing_error")] + #[translate_fields(cause = translate(0))] + // #[error("Indexing Error: {0}")] + Indexing(#[from] crate::search::indexing::IndexingError), + + #[translation_id("payments_error")] + #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String + // #[error("Payments Error: {0}")] + Payments(String), + + #[translation_id("discord_error")] + #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String + // #[error("Discord Error: {0}")] + Discord(String), + + #[translation_id("turnstile_error")] + // #[error("Captcha Error. Try resubmitting the form.")] + Turnstile, + + #[translation_id("decoding_error")] + #[translate_fields(cause = translate(0))] + // #[error("Error while decoding Base62: {0}")] + Decoding(#[from] ariadne::ids::DecodingError), + + #[translation_id("invalid_image")] + #[translate_fields(cause = 0)] + // #[error("Image Parsing Error: {0}")] + ImageParse(#[from] image::ImageError), + + #[translation_id("password_hashing_error")] + #[translate_fields(cause = 0)] + // #[error("Password Hashing Error: {0}")] + PasswordHashing(#[from] argon2::password_hash::Error), + + #[translation_id("mail_error")] + #[translate_fields(cause = translate(0))] + // #[error("{0}")] + Mail(#[from] crate::auth::email::MailError), + + #[translation_id("reroute_error")] + #[translate_fields(cause = 0)] + // #[error("Error while rerouting request: {0}")] + Reroute(#[from] reqwest::Error), + + #[translation_id("zip_error")] + #[translate_fields(cause = 0)] + // #[error("Unable to read Zip Archive: {0}")] + Zip(#[from] zip::result::ZipError), + + #[translation_id("io_error")] + #[translate_fields(cause = 0)] + // #[error("IO Error: {0}")] + Io(#[from] std::io::Error), + + // TODO: Add route not found + #[translation_id("not_found")] + // #[error("Resource not found")] + NotFound, + + #[translation_id("conflict")] + #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String + // #[error("Conflict: {0}")] + Conflict(String), + + #[translation_id("tax_compliance_api_error")] + // #[error("External tax compliance API Error")] + TaxComplianceApi, + + #[translation_id("ratelimit_error")] + #[translate_fields(wait_ms = 0, total_allowed_requests = 1)] + // #[error( + // "You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining." + // )] + RateLimitError(u128, u32), + + #[translation_id("stripe_error")] + #[translate_fields(cause = 0)] + // #[error("Error while interacting with payment processor: {0}")] + Stripe(#[from] stripe::StripeError), +} + +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::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, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(self.as_api_error()) + } +} + +#[macro_export] +macro_rules! labrinth_error_type { + ($error_enum:ty) => { + impl $error_enum { + pub fn as_api_error<'a>( + &self, + ) -> $crate::models::error::ApiError<'a> { + self.as_localized_api_error("en") + } + + pub fn as_localized_api_error<'a>( + &self, + language: &str, + ) -> crate::models::error::ApiError<'a> { + $crate::models::error::ApiError { + error: $crate::routes::error::error_id_for_error(self), + description: self.translated_message(language), + } + } + + pub fn localized_error_response( + &self, + req: &actix_web::HttpRequest, + ) -> actix_web::HttpResponse { + use actix_web::http::header::{ContentLanguage, QualityItem}; + + let language = + $crate::routes::error::parse_accept_language(req); + let body = self.as_localized_api_error(language.as_str()); + + actix_web::HttpResponse::build(self.status_code()) + .append_header(ContentLanguage(vec![QualityItem::max( + language, + )])) + .json(body) + } + } + }; +} + +labrinth_error_type!(ApiError); + +pub fn error_id_for_error(error: &impl I18nEnum) -> &'static str { + let translation_id = error.translation_id(); + translation_id + .split_once('.') + .map_or(translation_id, |(base, _)| base) +} + +pub fn parse_accept_language(req: &HttpRequest) -> LanguageTag { + AcceptLanguage::parse(req) + .ok() + .and_then(|x| x.preference().into_item()) + .unwrap_or_else(|| LanguageTag::parse("en").unwrap()) +} diff --git a/apps/labrinth/src/routes/internal/admin.rs b/apps/labrinth/src/routes/internal/admin.rs index 3be7e013f3..033872c2cf 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/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 43151220c9..d97b8016d4 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -16,7 +16,7 @@ use crate::models::billing::{ use crate::models::pats::Scopes; use crate::models::users::Badges; use crate::queue::session::AuthQueue; -use crate::routes::ApiError; +use crate::routes::error::ApiError; 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}; diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 2e244edf98..733043a563 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -11,7 +11,7 @@ use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::pats::Scopes; use crate::models::users::{Badges, Role}; use crate::queue::session::AuthQueue; -use crate::routes::ApiError; +use crate::routes::error::ApiError; use crate::routes::internal::session::issue_session; use crate::util::captcha::check_hcaptcha; use crate::util::env::parse_strings_from_var; diff --git a/apps/labrinth/src/routes/internal/gdpr.rs b/apps/labrinth/src/routes/internal/gdpr.rs index ecc8b98480..0c5e71e095 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 3330ab13ef..d2be0087e5 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -9,8 +9,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 fa10d97c5b..3dbcc41d0c 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 d8dc403584..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}; 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 fb724fbf97..53de0a1d36 100644 --- a/apps/labrinth/src/routes/maven.rs +++ b/apps/labrinth/src/routes/maven.rs @@ -9,7 +9,7 @@ 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 ariadne::i18n::localized_labrinth_error; diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 664189b415..1fe93a84fd 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -10,6 +10,7 @@ use actix_web::{HttpRequest, HttpResponse, ResponseError, web}; use ariadne::i18n::I18nEnum; use futures::FutureExt; +pub mod error; pub mod internal; pub mod v2; pub mod v3; @@ -86,253 +87,3 @@ pub fn root_config(cfg: &mut web::ServiceConfig) { .service(Files::new("/", "assets/")), ); } - -#[derive(thiserror::Error, I18nEnum, Debug)] -#[i18n_root_key("error")] -pub enum ApiError { - #[translation_id("environment_error")] - // #[error("Environment Error")] - Env(#[from] dotenvy::Error), - - #[translation_id("file_hosting_error")] - #[translate_fields(cause = translate(0))] - // #[error("Error while uploading file: {0}")] - FileHosting(#[from] FileHostingError), - - #[translation_id("database_error")] - #[translate_fields(cause = translate(0))] - // #[error("Database Error: {0}")] - Database(#[from] crate::database::models::DatabaseError), - - #[translation_id("database_error")] - #[translate_fields(cause = 0)] - // #[error("Database Error: {0}")] - SqlxDatabase(#[from] sqlx::Error), - - #[translation_id("database_error")] - #[translate_fields(cause = 0)] - // #[error("Database Error: {0}")] - RedisDatabase(#[from] redis::RedisError), - - #[translation_id("clickhouse_error")] - #[translate_fields(cause = 0)] - // #[error("Clickhouse Error: {0}")] - Clickhouse(#[from] clickhouse::error::Error), - - #[translation_id("xml_error")] - #[translate_fields(cause = 0)] - // TODO: Use an I18nEnum instead of a String - // #[error("Internal server error: {0}")] - Xml(String), - - #[translation_id("json_error")] - #[translate_fields(cause = 0)] - // #[error("Deserialization error: {0}")] - Json(#[from] serde_json::Error), - - #[translation_id("unauthorized")] - #[translate_fields(cause = translate(0))] - // #[error("Authentication Error: {0}")] - Authentication(#[from] crate::auth::AuthenticationError), - - #[translation_id("unauthorized")] - #[translate_fields(cause = 0)] - // TODO: Use an I18nEnum instead of a String - // #[error("Authentication Error: {0}")] - CustomAuthentication(String), - - #[translation_id("invalid_input")] - #[translate_fields(cause = 0)] - // TODO: Use an I18nEnum instead of a String - // #[error("Invalid Input: {0}")] - InvalidInput(String), - - // TODO: Perhaps remove this in favor of InvalidInput? - #[translation_id("invalid_input")] - #[translate_fields(cause = 0)] - // #[error("Error while validating input: {0}")] - Validation(String), - - #[translation_id("search_error")] - #[translate_fields(cause = 0)] - // #[error("Search Error: {0}")] - Search(#[from] meilisearch_sdk::errors::Error), - - #[translation_id("indexing_error")] - #[translate_fields(cause = translate(0))] - // #[error("Indexing Error: {0}")] - Indexing(#[from] crate::search::indexing::IndexingError), - - #[translation_id("payments_error")] - #[translate_fields(cause = 0)] - // TODO: Use an I18nEnum instead of a String - // #[error("Payments Error: {0}")] - Payments(String), - - #[translation_id("discord_error")] - #[translate_fields(cause = 0)] - // TODO: Use an I18nEnum instead of a String - // #[error("Discord Error: {0}")] - Discord(String), - - #[translation_id("turnstile_error")] - // #[error("Captcha Error. Try resubmitting the form.")] - Turnstile, - - #[translation_id("decoding_error")] - #[translate_fields(cause = translate(0))] - // #[error("Error while decoding Base62: {0}")] - Decoding(#[from] ariadne::ids::DecodingError), - - #[translation_id("invalid_image")] - #[translate_fields(cause = 0)] - // #[error("Image Parsing Error: {0}")] - ImageParse(#[from] image::ImageError), - - #[translation_id("password_hashing_error")] - #[translate_fields(cause = 0)] - // #[error("Password Hashing Error: {0}")] - PasswordHashing(#[from] argon2::password_hash::Error), - - #[translation_id("mail_error")] - #[translate_fields(cause = translate(0))] - // #[error("{0}")] - Mail(#[from] crate::auth::email::MailError), - - #[translation_id("reroute_error")] - #[translate_fields(cause = 0)] - // #[error("Error while rerouting request: {0}")] - Reroute(#[from] reqwest::Error), - - #[translation_id("zip_error")] - #[translate_fields(cause = 0)] - // #[error("Unable to read Zip Archive: {0}")] - Zip(#[from] zip::result::ZipError), - - #[translation_id("io_error")] - #[translate_fields(cause = 0)] - // #[error("IO Error: {0}")] - Io(#[from] std::io::Error), - - // TODO: Add route not found - #[translation_id("not_found")] - // #[error("Resource not found")] - NotFound, - - #[translation_id("conflict")] - #[translate_fields(cause = 0)] - // TODO: Use an I18nEnum instead of a String - // #[error("Conflict: {0}")] - Conflict(String), - - #[translation_id("tax_compliance_api_error")] - // #[error("External tax compliance API Error")] - TaxComplianceApi, - - #[translation_id("ratelimit_error")] - #[translate_fields(wait_ms = 0, total_allowed_requests = 1)] - // #[error( - // "You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining." - // )] - RateLimitError(u128, u32), - - #[translation_id("stripe_error")] - #[translate_fields(cause = 0)] - // #[error("Error while interacting with payment processor: {0}")] - Stripe(#[from] stripe::StripeError), -} - -#[macro_export] -macro_rules! labrinth_error_type { - ($error_enum:ty) => { - impl $error_enum { - pub fn as_api_error<'a>( - &self, - ) -> $crate::models::error::ApiError<'a> { - self.as_localized_api_error("en") - } - - pub fn as_localized_api_error<'a>( - &self, - language: &str, - ) -> crate::models::error::ApiError<'a> { - $crate::models::error::ApiError { - error: $crate::routes::error_id_for_error(self), - description: self.translated_message(language), - } - } - - pub fn localized_error_response( - &self, - req: &actix_web::HttpRequest, - ) -> actix_web::HttpResponse { - use actix_web::http::header::{ContentLanguage, QualityItem}; - - let language = $crate::routes::parse_accept_language(req); - let body = self.as_localized_api_error(language.as_str()); - - actix_web::HttpResponse::build(self.status_code()) - .append_header(ContentLanguage(vec![QualityItem::max( - language, - )])) - .json(body) - } - } - }; -} - -labrinth_error_type!(ApiError); - -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::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, - } - } - - fn error_response(&self) -> HttpResponse { - HttpResponse::build(self.status_code()).json(self.as_api_error()) - } -} - -pub fn parse_accept_language(req: &HttpRequest) -> LanguageTag { - AcceptLanguage::parse(req) - .ok() - .and_then(|x| x.preference().into_item()) - .unwrap_or_else(|| LanguageTag::parse("en").unwrap()) -} - -pub fn error_id_for_error(error: &impl I18nEnum) -> &'static str { - let translation_id = error.translation_id(); - translation_id - .split_once('.') - .map_or(translation_id, |(base, _)| base) -} diff --git a/apps/labrinth/src/routes/not_found.rs b/apps/labrinth/src/routes/not_found.rs index bdfcc2fef0..f0da5015d5 100644 --- a/apps/labrinth/src/routes/not_found.rs +++ b/apps/labrinth/src/routes/not_found.rs @@ -1,4 +1,4 @@ -use crate::routes::ApiError; +use crate::routes::error::ApiError; use actix_web::{HttpRequest, HttpResponse}; pub async fn not_found(req: HttpRequest) -> HttpResponse { 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/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_file.rs b/apps/labrinth/src/routes/v2/version_file.rs index 11d4a50f77..af6fbecd93 100644 --- a/apps/labrinth/src/routes/v2/version_file.rs +++ b/apps/labrinth/src/routes/v2/version_file.rs @@ -1,8 +1,8 @@ -use super::ApiError; 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..09ed91557e 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::util::actix::{ MultipartSegment, MultipartSegmentData, generate_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..f8dfbb184c 100644 --- a/apps/labrinth/src/routes/v3/collections.rs +++ b/apps/labrinth/src/routes/v3/collections.rs @@ -9,7 +9,7 @@ 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::error::ApiError; use crate::routes::v3::project_creation::CreateError; use crate::util::img::delete_old_images; use crate::util::routes::read_limited_from_payload; diff --git a/apps/labrinth/src/routes/v3/friends.rs b/apps/labrinth/src/routes/v3/friends.rs index 8ae42a951d..26c930486b 100644 --- a/apps/labrinth/src/routes/v3/friends.rs +++ b/apps/labrinth/src/routes/v3/friends.rs @@ -5,7 +5,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..eed4c4ba2e 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; use crate::util::img::upload_image_optimized; use crate::util::routes::read_limited_from_payload; use actix_web::{HttpRequest, HttpResponse, web}; diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 9b5040a9f0..4659c6deae 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -1,4 +1,4 @@ -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; diff --git a/apps/labrinth/src/routes/v3/notifications.rs b/apps/labrinth/src/routes/v3/notifications.rs index 57898a567e..58b117ec1c 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; use actix_web::{HttpRequest, HttpResponse, web}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; diff --git a/apps/labrinth/src/routes/v3/oauth_clients.rs b/apps/labrinth/src/routes/v3/oauth_clients.rs index e738c7e521..cbe6abdb2b 100644 --- a/apps/labrinth/src/routes/v3/oauth_clients.rs +++ b/apps/labrinth/src/routes/v3/oauth_clients.rs @@ -1,8 +1,8 @@ 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; use crate::util::img::{delete_old_images, upload_image_optimized}; use crate::{ auth::{checks::ValidateAuthorized, get_user_from_headers}, diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index da726d8309..5c5fd910a7 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,6 +12,7 @@ use crate::models::ids::OrganizationId; use crate::models::pats::Scopes; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::queue::session::AuthQueue; +use crate::routes::error::ApiError; use crate::routes::v3::project_creation::CreateError; use crate::util::img::delete_old_images; use crate::util::routes::read_limited_from_payload; diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs index 04cc339d13..9e07aec762 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/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 1d07f22682..02edaf738f 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; use crate::search::indexing::remove_documents; use crate::search::{SearchConfig, SearchError, search_for_project}; use crate::util::img; diff --git a/apps/labrinth/src/routes/v3/reports.rs b/apps/labrinth/src/routes/v3/reports.rs index ee00944408..5f08381de0 100644 --- a/apps/labrinth/src/routes/v3/reports.rs +++ b/apps/labrinth/src/routes/v3/reports.rs @@ -12,7 +12,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..aabb98177b 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; use crate::routes::v3::project_creation::UploadedFile; use crate::util::ext::MRPACK_MIME_TYPE; use actix_web::http::header::ContentLength; diff --git a/apps/labrinth/src/routes/v3/shared_instances.rs b/apps/labrinth/src/routes/v3/shared_instances.rs index fa1fc402ef..653ffaa8d4 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; use crate::util::routes::read_typed_from_payload; use actix_web::web::{Data, Redirect}; use actix_web::{HttpRequest, HttpResponse, web}; diff --git a/apps/labrinth/src/routes/v3/statistics.rs b/apps/labrinth/src/routes/v3/statistics.rs index 169ff000c4..b04d033eb2 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; 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 1a6b3e166f..926651c806 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; use actix_web::{HttpRequest, HttpResponse, web}; use ariadne::ids::UserId; use rust_decimal::Decimal; diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index b8785db676..9ec7050aa6 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; use actix_web::{HttpRequest, HttpResponse, web}; use futures::TryStreamExt; use serde::Deserialize; diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index 9f48fc6b81..0385c794df 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; use crate::{ auth::{ filter_visible_collections, filter_visible_projects, diff --git a/apps/labrinth/src/routes/v3/version_file.rs b/apps/labrinth/src/routes/v3/version_file.rs index 49567dd157..73468a214f 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::redis::RedisPool; @@ -7,6 +6,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; use crate::{database, models}; use actix_web::{HttpRequest, HttpResponse, web}; use dashmap::DashMap; diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index cdca240744..e0b3460068 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; use crate::search::SearchConfig; use crate::search::indexing::remove_documents; use crate::util::img; 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 84f41da2a8..4a978dfde6 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; use crate::util::env::parse_var; use actix_web::{ Error, diff --git a/apps/labrinth/src/util/routes.rs b/apps/labrinth/src/util/routes.rs index c963937213..05309c0687 100644 --- a/apps/labrinth/src/util/routes.rs +++ b/apps/labrinth/src/util/routes.rs @@ -1,4 +1,4 @@ -use crate::routes::ApiError; +use crate::routes::error::ApiError; use crate::routes::v3::project_creation::CreateError; use crate::util::validate::validation_errors_to_string; use actix_multipart::Field; diff --git a/apps/labrinth/src/util/webhook.rs b/apps/labrinth/src/util/webhook.rs index 1d7be03ab8..46b7a48915 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; From 8bcb5937236ca87ac4a7b1d0ba75749d31933453 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Tue, 9 Sep 2025 12:40:38 -0500 Subject: [PATCH 08/43] Get rid of I18nEnum proc macro in favor of regular macro --- Cargo.lock | 1 + apps/labrinth/src/auth/mod.rs | 37 ++- apps/labrinth/src/file_hosting/mod.rs | 17 +- apps/labrinth/src/routes/error.rs | 97 +++--- apps/labrinth/src/routes/mod.rs | 6 +- .../src/routes/v3/project_creation.rs | 78 ++--- packages/ariadne-macros/src/i18n_enum.rs | 275 ------------------ packages/ariadne-macros/src/lib.rs | 38 +-- packages/ariadne/Cargo.toml | 1 + packages/ariadne/package.json | 11 + packages/ariadne/src/i18n.rs | 241 ++++++++++++++- packages/ariadne/src/lib.rs | 3 + pnpm-lock.yaml | 2 + 13 files changed, 363 insertions(+), 444 deletions(-) delete mode 100644 packages/ariadne-macros/src/i18n_enum.rs create mode 100644 packages/ariadne/package.json diff --git a/Cargo.lock b/Cargo.lock index d67a19e622..eb7320d45f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -486,6 +486,7 @@ dependencies = [ "chrono", "either", "rand 0.8.5", + "rust-i18n", "serde", "serde_bytes", "serde_cbor", diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs index 060b347053..47263ddc3d 100644 --- a/apps/labrinth/src/auth/mod.rs +++ b/apps/labrinth/src/auth/mod.rs @@ -18,71 +18,76 @@ use crate::labrinth_error_type; use actix_web::http::StatusCode; use actix_web::{HttpResponse, ResponseError}; use ariadne::i18n::I18nEnum; +use ariadne::i18n_enum; use thiserror::Error; // TODO add fields -#[derive(Error, I18nEnum, Debug)] -#[i18n_root_key("error.unauthorized")] +#[derive(Error, Debug)] pub enum AuthenticationError { - #[translation_id("environment_error")] // #[error("Environment Error")] Env(#[from] dotenvy::Error), - #[translation_id("database_error")] // #[error("An unknown database error occurred: {0}")] Sqlx(#[from] sqlx::Error), - #[translation_id("database_error")] // #[error("Database Error: {0}")] Database(#[from] crate::database::models::DatabaseError), - #[translation_id("invalid_input")] // #[error("Error while parsing JSON: {0}")] SerDe(#[from] serde_json::Error), - #[translation_id("network_error")] // #[error("Error while communicating to external provider")] Reqwest(#[from] reqwest::Error), - #[translation_id("file_hosting")] // #[error("Error uploading user profile picture")] FileHosting(#[from] FileHostingError), - #[translation_id("decoding_error")] // #[error("Error while decoding PAT: {0}")] Decoding(#[from] ariadne::ids::DecodingError), - #[translation_id("mail_error")] // #[error("{0}")] Mail(#[from] email::MailError), - #[translation_id("invalid_credentials")] // #[error("Invalid Authentication Credentials")] InvalidCredentials, - #[translation_id("invalid_auth_method")] // #[error("Authentication method was not valid")] InvalidAuthMethod, - #[translation_id("invalid_client_id")] // #[error("GitHub Token from incorrect Client ID")] InvalidClientId, - #[translation_id("duplicate_user")] // #[error( // "User email is already registered on Modrinth. Try 'Forgot password' to access your account." // )] DuplicateUser, - #[translation_id("socket")] // #[error("Invalid state sent, you probably need to get a new websocket")] SocketError, - #[translation_id("url_error")] // #[error("Invalid callback URL specified")] Url, } +i18n_enum!( + AuthenticationError, + root_key: "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(cause) => "mail_error", + InvalidCredentials! => "invalid_credentials", + InvalidAuthMethod! => "invalid_auth_method", + InvalidClientId! => "invalid_client_id", + DuplicateUser! => "duplicate_user", + SocketError! => "socket", + Url! => "url_error", +); + labrinth_error_type!(AuthenticationError); impl ResponseError for AuthenticationError { diff --git a/apps/labrinth/src/file_hosting/mod.rs b/apps/labrinth/src/file_hosting/mod.rs index 134334153b..1a84f32703 100644 --- a/apps/labrinth/src/file_hosting/mod.rs +++ b/apps/labrinth/src/file_hosting/mod.rs @@ -5,29 +5,32 @@ mod mock; mod s3_host; use ariadne::i18n::I18nEnum; +use ariadne::i18n_enum; use bytes::Bytes; pub use mock::MockHost; pub use s3_host::{S3BucketConfig, S3Host}; -#[derive(Error, I18nEnum, Debug)] -#[i18n_root_key("error.file_hosting_error")] +#[derive(Error, Debug)] pub enum FileHostingError { - #[translation_id("s3")] - #[translate_fields(action = 0, cause = 1)] // TODO: Use an I18nEnum instead of a String // #[error("S3 error when {0}: {1}")] S3Error(&'static str, s3::error::S3Error), - #[translation_id("file_system")] - #[translate_fields(cause = 0)] // #[error("File system error in file hosting: {0}")] FileSystemError(#[from] std::io::Error), - #[translation_id("invalid_filename")] // #[error("Invalid Filename")] InvalidFilename, } +i18n_enum!( + FileHostingError, + root_key: "error.file_hosting_error", + S3Error(action, cause) => "s3", + FileSystemError(cause) => "file_system", + InvalidFilename! => "invalid_filename", +); + #[derive(Debug, Clone)] pub struct UploadFileData { pub file_name: String, diff --git a/apps/labrinth/src/routes/error.rs b/apps/labrinth/src/routes/error.rs index 9c13f40b05..2f7afa4750 100644 --- a/apps/labrinth/src/routes/error.rs +++ b/apps/labrinth/src/routes/error.rs @@ -1,165 +1,145 @@ use crate::file_hosting::FileHostingError; -use actix_http::StatusCode; -use actix_http::header::{Header, LanguageTag}; +use actix_web::http::StatusCode; use actix_web::http::header::AcceptLanguage; +use actix_web::http::header::{Header, LanguageTag}; use actix_web::{HttpRequest, HttpResponse, ResponseError}; use ariadne::i18n::I18nEnum; +use ariadne::i18n_enum; -#[derive(thiserror::Error, I18nEnum, Debug)] -#[i18n_root_key("error")] +#[derive(thiserror::Error, Debug)] pub enum ApiError { - #[translation_id("environment_error")] // #[error("Environment Error")] Env(#[from] dotenvy::Error), - #[translation_id("file_hosting_error")] - #[translate_fields(cause = translate(0))] // #[error("Error while uploading file: {0}")] FileHosting(#[from] FileHostingError), - #[translation_id("database_error")] - #[translate_fields(cause = translate(0))] // #[error("Database Error: {0}")] Database(#[from] crate::database::models::DatabaseError), - #[translation_id("database_error")] - #[translate_fields(cause = 0)] // #[error("Database Error: {0}")] SqlxDatabase(#[from] sqlx::Error), - #[translation_id("database_error")] - #[translate_fields(cause = 0)] // #[error("Database Error: {0}")] RedisDatabase(#[from] redis::RedisError), - #[translation_id("clickhouse_error")] - #[translate_fields(cause = 0)] // #[error("Clickhouse Error: {0}")] Clickhouse(#[from] clickhouse::error::Error), - #[translation_id("xml_error")] - #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String // #[error("Internal server error: {0}")] Xml(String), - #[translation_id("json_error")] - #[translate_fields(cause = 0)] // #[error("Deserialization error: {0}")] Json(#[from] serde_json::Error), - #[translation_id("unauthorized")] - #[translate_fields(cause = translate(0))] // #[error("Authentication Error: {0}")] Authentication(#[from] crate::auth::AuthenticationError), - #[translation_id("unauthorized")] - #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String // #[error("Authentication Error: {0}")] CustomAuthentication(String), - #[translation_id("invalid_input")] - #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String // #[error("Invalid Input: {0}")] InvalidInput(String), // TODO: Perhaps remove this in favor of InvalidInput? - #[translation_id("invalid_input")] - #[translate_fields(cause = 0)] // #[error("Error while validating input: {0}")] Validation(String), - #[translation_id("search_error")] - #[translate_fields(cause = 0)] // #[error("Search Error: {0}")] Search(#[from] meilisearch_sdk::errors::Error), - #[translation_id("indexing_error")] - #[translate_fields(cause = translate(0))] // #[error("Indexing Error: {0}")] Indexing(#[from] crate::search::indexing::IndexingError), - #[translation_id("payments_error")] - #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String // #[error("Payments Error: {0}")] Payments(String), - #[translation_id("discord_error")] - #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String // #[error("Discord Error: {0}")] Discord(String), - #[translation_id("turnstile_error")] // #[error("Captcha Error. Try resubmitting the form.")] Turnstile, - #[translation_id("decoding_error")] - #[translate_fields(cause = translate(0))] // #[error("Error while decoding Base62: {0}")] Decoding(#[from] ariadne::ids::DecodingError), - #[translation_id("invalid_image")] - #[translate_fields(cause = 0)] // #[error("Image Parsing Error: {0}")] ImageParse(#[from] image::ImageError), - #[translation_id("password_hashing_error")] - #[translate_fields(cause = 0)] // #[error("Password Hashing Error: {0}")] PasswordHashing(#[from] argon2::password_hash::Error), - #[translation_id("mail_error")] - #[translate_fields(cause = translate(0))] // #[error("{0}")] Mail(#[from] crate::auth::email::MailError), - #[translation_id("reroute_error")] - #[translate_fields(cause = 0)] // #[error("Error while rerouting request: {0}")] Reroute(#[from] reqwest::Error), - #[translation_id("zip_error")] - #[translate_fields(cause = 0)] // #[error("Unable to read Zip Archive: {0}")] Zip(#[from] zip::result::ZipError), - #[translation_id("io_error")] - #[translate_fields(cause = 0)] // #[error("IO Error: {0}")] Io(#[from] std::io::Error), // TODO: Add route not found - #[translation_id("not_found")] // #[error("Resource not found")] NotFound, - #[translation_id("conflict")] - #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String // #[error("Conflict: {0}")] Conflict(String), - #[translation_id("tax_compliance_api_error")] // #[error("External tax compliance API Error")] TaxComplianceApi, - #[translation_id("ratelimit_error")] - #[translate_fields(wait_ms = 0, total_allowed_requests = 1)] // #[error( // "You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining." // )] RateLimitError(u128, u32), - #[translation_id("stripe_error")] - #[translate_fields(cause = 0)] // #[error("Error while interacting with payment processor: {0}")] Stripe(#[from] stripe::StripeError), } +i18n_enum!( + ApiError, + root_key: "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", + CustomAuthentication(cause) => "unauthorized", + InvalidInput(cause) => "invalid_input", + Validation(cause) => "invalid_input.validation", + Search(cause) => "search_error", + Indexing(cause) => "indexing_error", + Payments(cause) => "payments_error", + Discord(cause) => "discord_error", + Turnstile! => "turnstile_error", + Decoding(cause) => "decoding_error", + ImageParse(cause) => "invalid_image", + PasswordHashing(cause) => "password_hashing_error", + Mail(cause) => "mail_error", + Reroute(cause) => "reroute_error", + Zip(cause) => "zip_error", + Io(cause) => "io_error", + NotFound! => "not_found", + Conflict(cause) => "conflict", + TaxComplianceApi! => "tax_compliance_api_error", + RateLimitError(wait_ms, total_allowed_requests) => "ratelimit_error", + Stripe(cause) => "stripe_error", +); + impl ResponseError for ApiError { fn status_code(&self) -> StatusCode { match self { @@ -224,6 +204,7 @@ macro_rules! labrinth_error_type { &self, req: &actix_web::HttpRequest, ) -> actix_web::HttpResponse { + use actix_web::ResponseError; use actix_web::http::header::{ContentLanguage, QualityItem}; let language = diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 1fe93a84fd..243ab7a8ba 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -1,13 +1,9 @@ -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::http::header::{AcceptLanguage, Header, LanguageTag}; -use actix_web::{HttpRequest, HttpResponse, ResponseError, web}; -use ariadne::i18n::I18nEnum; +use actix_web::{HttpResponse, web}; use futures::FutureExt; pub mod error; diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 69691df59b..35a264e175 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -1,3 +1,4 @@ +use super::ApiError; use super::version_creation::{InitialVersionData, try_create_version_fields}; use crate::auth::{AuthenticationError, get_user_from_headers}; use crate::database::models::loader_fields::{ @@ -27,6 +28,7 @@ use actix_web::http::StatusCode; use actix_web::web::{self, Data}; use actix_web::{HttpRequest, HttpResponse}; use ariadne::i18n::I18nEnum; +use ariadne::i18n_enum; use ariadne::ids::UserId; use ariadne::ids::base62_impl::to_base62; use chrono::Utc; @@ -45,108 +47,93 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.route("project", web::post().to(project_create)); } -#[derive(Error, I18nEnum, Debug)] -#[i18n_root_key("error.project_creation")] +#[derive(Error, Debug)] pub enum CreateError { - #[translation_id("environment_error")] // #[error("Environment Error")] EnvError(#[from] dotenvy::Error), - #[translation_id("database_error.unknown")] // #[error("An unknown database error occurred")] SqlxDatabaseError(#[from] sqlx::Error), - #[translation_id("database_error")] - #[translate_fields(cause = 0)] // #[error("Database Error: {0}")] DatabaseError(#[from] models::DatabaseError), - #[translation_id("indexing_error")] - #[translate_fields(cause = 0)] // #[error("Indexing Error: {0}")] IndexingError(#[from] IndexingError), - #[translation_id("invalid_input.multipart")] - #[translate_fields(cause = 0)] // #[error("Error while parsing multipart payload: {0}")] MultipartError(#[from] actix_multipart::MultipartError), - #[translation_id("invalid_input.parsing")] - #[translate_fields(cause = 0)] // #[error("Error while parsing JSON: {0}")] SerDeError(#[from] serde_json::Error), - #[translation_id("invalid_input.validation")] - #[translate_fields(cause = 0)] // #[error("Error while validating input: {0}")] ValidationError(String), - #[translation_id("file_hosting_error")] - #[translate_fields(cause = translate(0))] // #[error("Error while uploading file: {0}")] FileHostingError(#[from] FileHostingError), - #[translation_id("invalid_input.file")] - #[translate_fields(cause = translate(0))] // #[error("Error while validating uploaded file: {0}")] FileValidationError(#[from] crate::validate::ValidationError), - #[translation_id("invalid_input.missing_value")] - #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String // #[error("{}", .0)] MissingValueError(String), - #[translation_id("invalid_input.icon")] - #[translate_fields(cause = 0)] // #[error("Invalid format for image: {0}")] - InvalidIconFormat(String), + InvalidIconFormat(ApiError), - #[translation_id("invalid_input")] - #[translate_fields(cause = 0)] + // TODO: Use an I18nEnum instead of a String // #[error("Error with multipart data: {0}")] InvalidInput(String), - #[translation_id("invalid_input.game_version")] - #[translate_fields(cause = 0)] - // #[error("Invalid game version: {0}")] - InvalidGameVersion(String), - - #[translation_id("invalid_input.loader")] - #[translate_fields(cause = 0)] // #[error("Invalid loader: {0}")] InvalidLoader(String), - #[translation_id("invalid_input.category")] - #[translate_fields(cause = 0)] // #[error("Invalid category: {0}")] InvalidCategory(String), - #[translation_id("invalid_input.file_type")] - #[translate_fields(cause = 0)] // #[error("Invalid file type for version file: {0}")] InvalidFileType(String), - #[translation_id("invalid_input.slug_collision")] // #[error("Slug is already taken!")] SlugCollision, - #[translation_id("unauthorized")] - #[translate_fields(cause = translate(0))] // #[error("Authentication Error: {0}")] Unauthorized(#[from] AuthenticationError), - #[translation_id("unauthorized")] - #[translate_fields(cause = 0)] // TODO: Use an I18nEnum instead of a String // #[error("Authentication Error: {0}")] CustomAuthenticationError(String), - #[translation_id("invalid_image")] - #[translate_fields(cause = 0)] // #[error("Image Parsing Error: {0}")] ImageError(#[from] ImageError), } +i18n_enum!( + CreateError, + root_key: "error.project_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(cause) => "invalid_input.missing_value", + InvalidIconFormat(cause) => "invalid_input.icon", + InvalidInput(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) => "unauthorized", + CustomAuthenticationError(reason) => "unauthorized.custom", + ImageError(cause) => "invalid_image", +); + labrinth_error_type!(CreateError); impl actix_web::ResponseError for CreateError { @@ -166,7 +153,6 @@ impl actix_web::ResponseError for CreateError { 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, @@ -559,7 +545,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, @@ -1054,7 +1040,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/packages/ariadne-macros/src/i18n_enum.rs b/packages/ariadne-macros/src/i18n_enum.rs deleted file mode 100644 index 96b74f29f1..0000000000 --- a/packages/ariadne-macros/src/i18n_enum.rs +++ /dev/null @@ -1,275 +0,0 @@ -use proc_macro::TokenStream; -use proc_macro2::Span; -use quote::{ - IdentFragment, ToTokens, TokenStreamExt, format_ident, quote, quote_spanned, -}; -use std::collections::HashMap; -use std::mem; -use syn::parse::{Parse, ParseStream}; -use syn::{ - Attribute, Data, DeriveInput, Error, Fields, Ident, Index, LitStr, Member, - Result, Token, Variant, parenthesized, -}; - -pub fn generate_impls(input: DeriveInput) -> Result { - let enum_name = input.ident; - let i18n_root_key = find_i18n_root_key(&enum_name, input.attrs)?; - let Data::Enum(enum_data) = input.data else { - return Err(Error::new( - enum_name.span(), - "I18nEnum only supports enums. Please place the macro on the underlying error type.", - )); - }; - - let variants = parse_variants(enum_data.variants)?; - - let translation_id_cases = variants.iter().map(|variant| { - let name = &variant.name; - let pattern_format = &variant.pattern_format; - let id = variant.translation_id.as_ref().unwrap(); - quote! { - Self::#name #pattern_format => #id, - } - }); - - let message_cases = variants.iter().map(|variant| { - let name = &variant.name; - let pattern_format = &variant.pattern_format; - let message_key = format!( - "{i18n_root_key}.{}", - variant.translation_id.as_ref().unwrap().value() - ); - let params = variant - .translate_fields - .as_ref() - .unwrap() - .0 - .iter() - .map(|(param_name, field)| quote! { #param_name = #field }); - quote! { - Self::#name #pattern_format => - ::rust_i18n::t!(#message_key, locale = locale #(, #params)*), - } - }); - - let result = quote! { - impl ::ariadne::i18n::I18nEnum for #enum_name { - fn translation_id(&self) -> &'static str { - match self { - #(#translation_id_cases)* - } - } - - fn translated_message<'a>(&self, locale: &str) -> ::std::borrow::Cow<'a, str> { - match self { - #(#message_cases)* - } - } - } - - impl ::std::fmt::Display for #enum_name { - fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { - f.write_str(&self.translated_message("en")) - } - } - } - .into(); - - Ok(result) -} - -fn find_i18n_root_key( - enum_name: &Ident, - attrs: Vec, -) -> Result { - for attr in attrs { - if attr.path().is_ident("i18n_root_key") { - let key: LitStr = attr.parse_args()?; - return Ok(key.value()); - } - } - Err(Error::new( - enum_name.span(), - "Missing #[i18n_root_key] attribute", - )) -} - -fn parse_variants( - variants: impl IntoIterator, -) -> Result> { - let mut result = variants.into_iter().map(VariantData::parse).collect(); - validate_variants(&mut result)?; - Ok(result) -} - -struct VariantData { - name: Ident, - pattern_format: PatternFormat, - translation_id: Result, - translate_fields: Result, -} - -impl VariantData { - fn parse(variant: Variant) -> Self { - let name = variant.ident; - let mut translation_id = None; - let mut translate_fields = None; - for attr in variant.attrs { - if attr.path().is_ident("translation_id") { - translation_id = Some(attr.parse_args()); - } else if attr.path().is_ident("translate_fields") { - translate_fields = Some(attr.parse_args()); - } - } - Self { - translation_id: translation_id.unwrap_or_else(|| { - Err(Error::new( - name.span(), - format!("Missing #[translation_id] for variant {}", name), - )) - }), - translate_fields: translate_fields - .unwrap_or_else(|| Ok(Default::default())), - - name, - pattern_format: PatternFormat::from_fields(variant.fields), - } - } -} - -enum PatternFormat { - Named(Vec), - Tuple(usize), - Unit, -} - -impl PatternFormat { - fn from_fields(fields: Fields) -> Self { - match fields { - Fields::Named(named) => Self::Named( - named.named.into_iter().map(|x| x.ident.unwrap()).collect(), - ), - Fields::Unnamed(unnamed) => Self::Tuple(unnamed.unnamed.len()), - Fields::Unit => Self::Unit, - } - } -} - -impl ToTokens for PatternFormat { - fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { - match self { - PatternFormat::Named(names) => tokens.append_all(quote! { - { #(#names),* } - }), - PatternFormat::Tuple(length) => { - let names = (0..*length).map(|x| format_ident!("_{x}")); - tokens.append_all(quote! { - ( #(#names),* ) - }); - } - PatternFormat::Unit => {} - } - } -} - -#[derive(Default)] -struct TranslateFields(HashMap); - -impl Parse for TranslateFields { - fn parse(input: ParseStream) -> Result { - let result = input.parse_terminated( - |input| { - let key = input.parse::()?; - input.parse::()?; - let value = input.parse::()?; - Ok((key, value)) - }, - Token![,], - )?; - Ok(Self(result.into_iter().collect())) - } -} - -struct TranslateField { - member: Member, - translated: bool, - span: Span, -} - -impl ToTokens for TranslateField { - fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { - let member = match &self.member { - Member::Named(ident) => ident.to_token_stream(), - Member::Unnamed(Index { index, span }) => { - format_ident!("_{index}", span = *span).to_token_stream() - } - }; - let span = self.span; - if self.translated { - tokens.append_all(quote_spanned! {span=> - ::ariadne::i18n::I18nEnum::translated_message(#member, locale) - }); - } else { - member.to_tokens(tokens) - } - } -} - -impl Parse for TranslateField { - fn parse(input: ParseStream) -> Result { - mod kw { - syn::custom_keyword!(translate); - } - let translated = input.peek(kw::translate); - let member: Member; - let span: Span; - if translated { - let keyword = input.parse::()?; - let content; - let parens = parenthesized!(content in input); - member = content.parse()?; - span = keyword - .span - .join(parens.span.join()) - .or_else(|| member.span()) - .unwrap(); - } else { - member = input.parse()?; - span = member.span().unwrap(); - } - Ok(Self { - member, - translated, - span, - }) - } -} - -fn validate_variants(variants: &mut Vec) -> Result<()> { - fn take_err( - variant_name: &Ident, - value: &mut Result, - ) -> Option { - match value { - Ok(_) => None, - Err(e) => { - Some(mem::replace(e, Error::new(variant_name.span(), ""))) - } - } - } - - variants - .iter_mut() - .flat_map(|x| { - vec![ - take_err(&x.name, &mut x.translation_id), - take_err(&x.name, &mut x.translate_fields), - ] - }) - .filter_map(|x| x) - .reduce(|mut a, b| { - a.extend(b); - a - }) - .map_or(Ok(()), Err) -} diff --git a/packages/ariadne-macros/src/lib.rs b/packages/ariadne-macros/src/lib.rs index 12780634e0..9efd6c6d55 100644 --- a/packages/ariadne-macros/src/lib.rs +++ b/packages/ariadne-macros/src/lib.rs @@ -1,39 +1,5 @@ -mod i18n_enum; - +#[cfg(feature = "labrinth")] use proc_macro::TokenStream; -use syn::{DeriveInput, parse_macro_input}; - -/// This derive macro defines three attributes: -/// - `i18n_root_key`: Placed on the enum itself to specify a root translation key -/// - `error_id`: Placed on each enum element to define a string ID for errors. This will be used -/// for translation keys -/// - `translate_fields`: Optionally placed on each enum element to pass field names to the -/// translation, interpolated with `%(field_name)` in the translations. The member name can be -/// surrounded with `translate` to recursively translate it -/// -/// Example: -/// ``` -/// #[derive(I18nEnum)] -/// #[i18n_root_key("error.example")] -/// enum ExampleEnum { -/// #[error_id("example")] -/// #[field_names(cause = 0)] -/// Example(SomeEnum), -/// -/// #[error_id("translated_example")] -/// #[field_names(cause = translate(0))] -/// TranslatedExample(SomeTranslatableEnum), -/// } -/// ``` -#[proc_macro_derive( - I18nEnum, - attributes(i18n_root_key, translation_id, translate_fields) -)] -pub fn i18n_enum(item: TokenStream) -> TokenStream { - let input = parse_macro_input!(item as DeriveInput); - i18n_enum::generate_impls(input) - .unwrap_or_else(|err| err.to_compile_error().into()) -} // This exists purely to work around https://github.com/actix/actix-web/issues/2925 #[cfg(feature = "labrinth")] @@ -44,7 +10,7 @@ pub fn localized_labrinth_error( ) -> TokenStream { use quote::quote; use syn::spanned::Spanned; - use syn::{Error, FnArg, ItemFn, PatType, parse_quote}; + use syn::{Error, FnArg, ItemFn, PatType, parse_macro_input, parse_quote}; let function = parse_macro_input!(input as ItemFn); diff --git a/packages/ariadne/Cargo.toml b/packages/ariadne/Cargo.toml index 006363620c..7a001a1578 100644 --- a/packages/ariadne/Cargo.toml +++ b/packages/ariadne/Cargo.toml @@ -13,6 +13,7 @@ rand.workspace = true either.workspace = true chrono = { workspace = true, features = ["serde"] } serde_cbor.workspace = true +rust-i18n.workspace = true ariadne-macros.workspace = true 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 index f622b238f4..b584b97254 100644 --- a/packages/ariadne/src/i18n.rs +++ b/packages/ariadne/src/i18n.rs @@ -1,9 +1,248 @@ use std::borrow::Cow; -pub use ariadne_macros::*; +#[cfg(feature = "labrinth")] +pub use ariadne_macros::localized_labrinth_error; +pub use rust_i18n::{i18n, t}; pub trait I18nEnum { + const ROOT_TRANSLATION_ID: &'static str; + fn translation_id(&self) -> &'static str; + fn full_translation_id(&self) -> &'static str; + fn translated_message<'a>(&self, locale: &str) -> Cow<'a, str>; } + +#[macro_export] +macro_rules! i18n_enum { + ( + $for_enum:ty, + root_key: $root_key:literal, + _ => $key:literal, + ) => { + #[allow(unused_variables)] // Rust doesn't see the variables from $variant get used for some rason + impl $crate::i18n::I18nEnum for $for_enum { + const ROOT_TRANSLATION_ID: &'static str = $root_key; + + fn translation_id(&self) -> &'static str { + $key + } + + fn full_translation_id(&self) -> &'static str { + concat!($root_key, ".", $key) + } + + fn translated_message<'a>(&self, locale: &str) -> ::std::borrow::Cow<'a, str> { + $crate::i18n::t!(concat!($root_key, ".", $key), locale = locale) + } + } + + impl ::std::fmt::Display for $for_enum { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str(&self.translated_message("en")) + } + } + }; + + ( + $for_enum:ty, + 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) => concat!($root_key, ".", $key),)* + } + } + + fn translated_message<'a>(&self, locale: &str) -> ::std::borrow::Cow<'a, str> { + trait __TranslatableEnum { + fn __maybe_translate<'a>(&self, locale: &str) -> ::std::borrow::Cow<'a, str>; + } + impl __TranslatableEnum for T { + fn __maybe_translate<'a>(&self, locale: &str) -> ::std::borrow::Cow<'a, str> { + self.translated_message(locale) + } + } + trait __NonTranslatableValue { + fn __maybe_translate<'a>(&self, _locale: &str) -> ::std::borrow::Cow<'a, str>; + } + impl __NonTranslatableValue for &T { + fn __maybe_translate<'a>(&self, _locale: &str) -> ::std::borrow::Cow<'a, str> { + ::std::borrow::Cow::Owned(self.to_string()) + } + } + use $for_enum::*; + match self { + $( + $crate::__i18n_enum_variant_parameters!($variant_name, $variant_pat) => + $crate::__i18n_enum_variant_values!($root_key, $key, locale, $variant_pat), + )* + } + } + } + + impl ::std::fmt::Display for $for_enum { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str(&self.translated_message("en")) + } + } + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! __i18n_enum_variant_parameters_no_store { + ($variant_name: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, ($($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, $locale:ident, !) => { + $crate::i18n::t!(concat!($root_key, ".", $key), locale = $locale) + }; + ($root_key:literal, $key:literal, $locale:ident, (..)) => { + $crate::i18n::t!(concat!($root_key, ".", $key), locale = $locale) + }; + ($root_key:literal, $key:literal, $locale:ident, {..}) => { + $crate::i18n::t!(concat!($root_key, ".", $key), locale = $locale) + }; + ($root_key:literal, $key:literal, $locale:ident, ($($field:ident),*)) => { + $crate::i18n::t!(concat!($root_key, ".", $key), locale = $locale $(, $field = $field.__maybe_translate($locale))*) + }; + ($root_key:literal, $key:literal, $locale:ident, {$($field:ident),*}) => { + $crate::i18n::t!(concat!($root_key, ".", $key), locale = $locale $(, $field = $field.__maybe_translate($locale))*) + }; +} + +#[cfg(test)] +#[doc(hidden)] +pub mod test { + use super::*; + + pub struct TestingBackend; + + impl rust_i18n::Backend for TestingBackend { + fn available_locales(&self) -> Vec<&str> { + vec!["en", "ja"] + } + + fn translate(&self, locale: &str, key: &str) -> Option<&str> { + let value = match locale { + "en" => match key { + "unit_translatable.unit" => "Unit Translatable", + "base.unit" => "Unit", + "base.tuple" => "Tuple: %{value}", + "base.translatable_tuple" => "Translatable Tuple: %{unit}", + "base.named" => "Named: %{subfield}", + _ => panic!("No translation for {key}"), + }, + "ja" => match key { + "unit_translatable.unit" => "この単位は翻訳できる", + "base.unit" => "単位", + "base.tuple" => "組: %{value}", + "base.translatable_tuple" => { + "この組は翻訳できる: %{unit}" + } + _ => self.translate("en", key)?, + }, + _ => panic!("No translations for {locale}"), + }; + Some(value) + } + } + + struct UnitTranslatable; + + i18n_enum!( + UnitTranslatable, + root_key: "unit_translatable", + _ => "unit", + ); + + enum TestEnum { + Unit, + Tuple(&'static str), + TranslatableTuple(UnitTranslatable), + Named { subfield: &'static str }, + } + + i18n_enum!( + TestEnum, + root_key: "base", + Unit! => "unit", + Tuple(value) => "tuple", + TranslatableTuple(unit) => "translatable_tuple", + Named { subfield } => "named", + ); + + fn assert_i18n_eq(x: impl I18nEnum, lang: &str, should_be: &str) { + assert_eq!(x.translated_message(lang), should_be); + } + + #[test] + fn test_en() { + assert_i18n_eq(UnitTranslatable, "en", "Unit Translatable"); + assert_i18n_eq(TestEnum::Unit, "en", "Unit"); + assert_i18n_eq(TestEnum::Tuple("hello"), "en", "Tuple: hello"); + assert_i18n_eq( + TestEnum::TranslatableTuple(UnitTranslatable), + "en", + "Translatable Tuple: Unit Translatable", + ); + assert_i18n_eq( + TestEnum::Named { + subfield: "Subfield", + }, + "en", + "Named: Subfield", + ); + } + + #[test] + fn test_ja() { + assert_i18n_eq(UnitTranslatable, "ja", "この単位は翻訳できる"); + assert_i18n_eq(TestEnum::Unit, "ja", "単位"); + assert_i18n_eq(TestEnum::Tuple("こんにちは"), "ja", "組: こんにちは"); + assert_i18n_eq( + TestEnum::TranslatableTuple(UnitTranslatable), + "ja", + "この組は翻訳できる: この単位は翻訳できる", + ); + } +} diff --git a/packages/ariadne/src/lib.rs b/packages/ariadne/src/lib.rs index 9335e035df..3e7830687d 100644 --- a/packages/ariadne/src/lib.rs +++ b/packages/ariadne/src/lib.rs @@ -3,3 +3,6 @@ pub mod ids; pub mod networking; pub mod users; pub mod versions; + +#[cfg(test)] +i18n::i18n!(backend = i18n::test::TestingBackend); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f49ee444ab..0934bbc2ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -377,6 +377,8 @@ importers: packages/app-lib: {} + packages/ariadne: {} + packages/assets: devDependencies: '@modrinth/tooling-config': From febf5faa5411378e5cb0df79f128ea244b1963e7 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Tue, 9 Sep 2025 12:59:08 -0500 Subject: [PATCH 09/43] Make some small fixes --- apps/labrinth/src/database/models/mod.rs | 29 +++++++++++++++++------- apps/labrinth/src/file_hosting/mod.rs | 1 - packages/ariadne/src/i18n.rs | 19 ++++++++-------- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 25f2ff11f1..664febb188 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 categories; @@ -43,18 +44,30 @@ pub use version_item::DBVersion; #[derive(Error, Debug)] pub enum DatabaseError { - #[error("Error while interacting with the database: {0}")] + // #[error("Error while interacting with the database: {0}")] Database(#[from] sqlx::Error), - #[error("Error while trying to generate random ID")] + // #[error("Error while trying to generate random ID")] RandomId, - #[error("Error while interacting with the cache: {0}")] + // #[error("Error while interacting with the cache: {0}")] CacheError(#[from] redis::RedisError), - #[error("Redis Pool Error: {0}")] + // #[error("Redis Pool Error: {0}")] RedisPool(#[from] deadpool_redis::PoolError), - #[error("Error while serializing with the cache: {0}")] + // #[error("Error while serializing with the cache: {0}")] SerdeCacheError(#[from] serde_json::Error), - #[error("Schema error: {0}")] - SchemaError(String), - #[error("Timeout when waiting for cache subscriber")] + // #[error("Schema error: {0}")] + SchemaError(String), // TODO: Use I18nEnum instead of String + // #[error("Timeout when waiting for cache subscriber")] CacheTimeout, } + +i18n_enum!( + DatabaseError, + root_key: "error.database", + Database(cause) => "sqlx", + RandomId! => "random_id", + CacheError(cause) => "cache", + RedisPool(cause) => "redix_pool", + SerdeCacheError(cause) => "cache_serialization", + SchemaError(cause) => "schema", + CacheTimeout! => "cache_timeout", +); diff --git a/apps/labrinth/src/file_hosting/mod.rs b/apps/labrinth/src/file_hosting/mod.rs index 1a84f32703..d183b0ebab 100644 --- a/apps/labrinth/src/file_hosting/mod.rs +++ b/apps/labrinth/src/file_hosting/mod.rs @@ -4,7 +4,6 @@ use thiserror::Error; mod mock; mod s3_host; -use ariadne::i18n::I18nEnum; use ariadne::i18n_enum; use bytes::Bytes; pub use mock::MockHost; diff --git a/packages/ariadne/src/i18n.rs b/packages/ariadne/src/i18n.rs index b584b97254..7d70fcf007 100644 --- a/packages/ariadne/src/i18n.rs +++ b/packages/ariadne/src/i18n.rs @@ -2,7 +2,6 @@ use std::borrow::Cow; #[cfg(feature = "labrinth")] pub use ariadne_macros::localized_labrinth_error; -pub use rust_i18n::{i18n, t}; pub trait I18nEnum { const ROOT_TRANSLATION_ID: &'static str; @@ -34,7 +33,7 @@ macro_rules! i18n_enum { } fn translated_message<'a>(&self, locale: &str) -> ::std::borrow::Cow<'a, str> { - $crate::i18n::t!(concat!($root_key, ".", $key), locale = locale) + ::rust_i18n::t!(concat!($root_key, ".", $key), locale = locale) } } @@ -73,7 +72,7 @@ macro_rules! i18n_enum { } impl __TranslatableEnum for T { fn __maybe_translate<'a>(&self, locale: &str) -> ::std::borrow::Cow<'a, str> { - self.translated_message(locale) + $crate::i18n::I18nEnum::translated_message(self, locale) } } trait __NonTranslatableValue { @@ -81,7 +80,7 @@ macro_rules! i18n_enum { } impl __NonTranslatableValue for &T { fn __maybe_translate<'a>(&self, _locale: &str) -> ::std::borrow::Cow<'a, str> { - ::std::borrow::Cow::Owned(self.to_string()) + ::std::borrow::Cow::Owned(::std::string::ToString::to_string(self)) } } use $for_enum::*; @@ -96,7 +95,7 @@ macro_rules! i18n_enum { impl ::std::fmt::Display for $for_enum { fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { - f.write_str(&self.translated_message("en")) + f.write_str(&$crate::i18n::I18nEnum::translated_message(self, "en")) } } }; @@ -134,19 +133,19 @@ macro_rules! __i18n_enum_variant_parameters { #[doc(hidden)] macro_rules! __i18n_enum_variant_values { ($root_key:literal, $key:literal, $locale:ident, !) => { - $crate::i18n::t!(concat!($root_key, ".", $key), locale = $locale) + ::rust_i18n::t!(concat!($root_key, ".", $key), locale = $locale) }; ($root_key:literal, $key:literal, $locale:ident, (..)) => { - $crate::i18n::t!(concat!($root_key, ".", $key), locale = $locale) + ::rust_i18n::t!(concat!($root_key, ".", $key), locale = $locale) }; ($root_key:literal, $key:literal, $locale:ident, {..}) => { - $crate::i18n::t!(concat!($root_key, ".", $key), locale = $locale) + ::rust_i18n::t!(concat!($root_key, ".", $key), locale = $locale) }; ($root_key:literal, $key:literal, $locale:ident, ($($field:ident),*)) => { - $crate::i18n::t!(concat!($root_key, ".", $key), locale = $locale $(, $field = $field.__maybe_translate($locale))*) + ::rust_i18n::t!(concat!($root_key, ".", $key), locale = $locale $(, $field = $field.__maybe_translate($locale))*) }; ($root_key:literal, $key:literal, $locale:ident, {$($field:ident),*}) => { - $crate::i18n::t!(concat!($root_key, ".", $key), locale = $locale $(, $field = $field.__maybe_translate($locale))*) + ::rust_i18n::t!(concat!($root_key, ".", $key), locale = $locale $(, $field = $field.__maybe_translate($locale))*) }; } From 617e3418ddf9071ee4ba1ab9919294fcdf5f8a6c Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Wed, 10 Sep 2025 11:09:41 -0500 Subject: [PATCH 10/43] Remove actual translation from the backend, instead generating objects containing information on how to translate --- .github/workflows/labrinth-docker.yml | 2 - Cargo.lock | 170 ----------------- Cargo.toml | 3 - apps/labrinth/Cargo.toml | 4 +- apps/labrinth/src/auth/mod.rs | 38 ++-- apps/labrinth/src/auth/oauth/errors.rs | 1 + apps/labrinth/src/auth/templates/mod.rs | 32 +--- apps/labrinth/src/database/models/mod.rs | 16 +- apps/labrinth/src/file_hosting/mod.rs | 6 +- apps/labrinth/src/lib.rs | 3 - apps/labrinth/src/models/error.rs | 34 +++- apps/labrinth/src/queue/moderation.rs | 1 + apps/labrinth/src/routes/error.rs | 131 ++++--------- apps/labrinth/src/routes/internal/flows.rs | 2 +- apps/labrinth/src/routes/maven.rs | 2 - apps/labrinth/src/routes/not_found.rs | 6 +- apps/labrinth/src/routes/v3/friends.rs | 2 - .../src/routes/v3/project_creation.rs | 45 ++--- apps/labrinth/src/util/ratelimit.rs | 6 +- packages/ariadne-macros/Cargo.toml | 18 -- packages/ariadne-macros/src/lib.rs | 47 ----- packages/ariadne/Cargo.toml | 6 - packages/ariadne/src/i18n.rs | 179 +++++++++--------- packages/ariadne/src/lib.rs | 3 - 24 files changed, 221 insertions(+), 536 deletions(-) delete mode 100644 packages/ariadne-macros/Cargo.toml delete mode 100644 packages/ariadne-macros/src/lib.rs diff --git a/.github/workflows/labrinth-docker.yml b/.github/workflows/labrinth-docker.yml index e3191c0f23..9f285197a2 100644 --- a/.github/workflows/labrinth-docker.yml +++ b/.github/workflows/labrinth-docker.yml @@ -8,14 +8,12 @@ on: - .github/workflows/labrinth-docker.yml - 'apps/labrinth/**' - 'packages/ariadne/**' - - 'packages/ariadne-macros/**' pull_request: types: [opened, synchronize] paths: - .github/workflows/labrinth-docker.yml - 'apps/labrinth/**' - 'packages/ariadne/**' - - 'packages/ariadne-macros/**' merge_group: types: [checks_requested] diff --git a/Cargo.lock b/Cargo.lock index eb7320d45f..265e4b674f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -449,12 +449,6 @@ dependencies = [ "derive_arbitrary", ] -[[package]] -name = "arc-swap" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" - [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -482,11 +476,9 @@ dependencies = [ name = "ariadne" version = "0.1.0" dependencies = [ - "ariadne-macros", "chrono", "either", "rand 0.8.5", - "rust-i18n", "serde", "serde_bytes", "serde_cbor", @@ -495,15 +487,6 @@ dependencies = [ "uuid 1.17.0", ] -[[package]] -name = "ariadne-macros" -version = "0.1.0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "arrayvec" version = "0.7.6" @@ -984,15 +967,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" -[[package]] -name = "base62" -version = "2.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0104d4d8d15e458f21dcd027ea350bf38e4364954909402f4da075aca8d0f136" -dependencies = [ - "rustversion", -] - [[package]] name = "base64" version = "0.13.1" @@ -1171,7 +1145,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" dependencies = [ "memchr", - "serde", ] [[package]] @@ -3222,30 +3195,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" -[[package]] -name = "globset" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", -] - -[[package]] -name = "globwalk" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" -dependencies = [ - "bitflags 1.3.2", - "ignore", - "walkdir", -] - [[package]] name = "gobject-sys" version = "0.18.0" @@ -3912,22 +3861,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "ignore" -version = "0.4.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata 0.4.9", - "same-file", - "walkdir", - "winapi-util", -] - [[package]] name = "image" version = "0.25.6" @@ -4175,15 +4108,6 @@ dependencies = [ "nom 8.0.0", ] -[[package]] -name = "itertools" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.12.1" @@ -4458,7 +4382,6 @@ dependencies = [ "redis", "regex", "reqwest", - "rust-i18n", "rust-s3", "rust_decimal", "rust_iso3166", @@ -5128,15 +5051,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" -[[package]] -name = "normpath" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8911957c4b1549ac0dc74e30db9c8b0e66ddcd6d7acc33098f4c63a64a6d7ed" -dependencies = [ - "windows-sys 0.59.0", -] - [[package]] name = "notify" version = "8.2.0" @@ -7067,60 +6981,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rust-i18n" -version = "3.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" -dependencies = [ - "globwalk", - "once_cell", - "regex", - "rust-i18n-macro", - "rust-i18n-support", - "smallvec", -] - -[[package]] -name = "rust-i18n-macro" -version = "3.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" -dependencies = [ - "glob", - "once_cell", - "proc-macro2", - "quote", - "rust-i18n-support", - "serde", - "serde_json", - "serde_yaml", - "syn 2.0.106", -] - -[[package]] -name = "rust-i18n-support" -version = "3.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" -dependencies = [ - "arc-swap", - "base62", - "globwalk", - "itertools 0.11.0", - "lazy_static", - "normpath", - "once_cell", - "proc-macro2", - "regex", - "serde", - "serde_json", - "serde_yaml", - "siphasher 1.0.1", - "toml 0.8.23", - "triomphe", -] - [[package]] name = "rust-ini" version = "0.21.2" @@ -7929,19 +7789,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.10.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "serialize-to-javascript" version = "0.1.1" @@ -9825,17 +9672,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "triomphe" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" -dependencies = [ - "arc-swap", - "serde", - "stable_deref_trait", -] - [[package]] name = "try-lock" version = "0.2.5" @@ -9997,12 +9833,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 852d0e677e..d289626272 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ members = [ "apps/labrinth", "packages/app-lib", "packages/ariadne", - "packages/ariadne-macros", "packages/daedalus", ] @@ -80,7 +79,6 @@ indicatif = "0.18.0" itertools = "0.14.0" jemalloc_pprof = "0.8.1" json-patch = { version = "4.0.0", default-features = false } -ariadne-macros = { path = "packages/ariadne-macros" } lettre = { version = "0.11.18", default-features = false, features = [ "builder", "hostname", @@ -113,7 +111,6 @@ reqwest = { version = "0.12.22", default-features = false } rgb = "0.8.52" rust_decimal = { version = "1.37.2", features = ["serde-with-float", "serde-with-str"] } rust_iso3166 = "0.1.14" -rust-i18n = "3.1.5" rust-s3 = { version = "0.35.1", default-features = false, features = [ "fail-on-err", "tags", diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 3e0ad62db3..c0dc09a90b 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -124,13 +124,11 @@ lettre.workspace = true rust_iso3166.workspace = true -rust-i18n.workspace = true - async-stripe = { workspace = true, features = ["billing", "checkout", "connect", "webhook-events"] } rusty-money.workspace = true json-patch.workspace = true -ariadne = { workspace = true, features = ["labrinth"] } +ariadne.workspace = true clap = { workspace = true, features = ["derive"] } diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs index 47263ddc3d..f527db2b03 100644 --- a/apps/labrinth/src/auth/mod.rs +++ b/apps/labrinth/src/auth/mod.rs @@ -14,58 +14,56 @@ use serde::{Deserialize, Serialize}; pub use validate::{check_is_moderator_from_headers, get_user_from_headers}; use crate::file_hosting::FileHostingError; -use crate::labrinth_error_type; +use crate::models::error::AsApiError; use actix_web::http::StatusCode; use actix_web::{HttpResponse, ResponseError}; -use ariadne::i18n::I18nEnum; use ariadne::i18n_enum; use thiserror::Error; -// TODO add fields #[derive(Error, Debug)] pub enum AuthenticationError { - // #[error("Environment Error")] + #[error("Environment Error")] Env(#[from] dotenvy::Error), - // #[error("An unknown database error occurred: {0}")] + #[error("An unknown database error occurred: {0}")] Sqlx(#[from] sqlx::Error), - // #[error("Database Error: {0}")] + #[error("Database Error: {0}")] Database(#[from] crate::database::models::DatabaseError), - // #[error("Error while parsing JSON: {0}")] + #[error("Error while parsing JSON: {0}")] SerDe(#[from] serde_json::Error), - // #[error("Error while communicating to external provider")] + #[error("Error while communicating to external provider")] Reqwest(#[from] reqwest::Error), - // #[error("Error uploading user profile picture")] + #[error("Error uploading user profile picture")] FileHosting(#[from] FileHostingError), - // #[error("Error while decoding PAT: {0}")] + #[error("Error while decoding PAT: {0}")] Decoding(#[from] ariadne::ids::DecodingError), - // #[error("{0}")] + #[error("{0}")] Mail(#[from] email::MailError), - // #[error("Invalid Authentication Credentials")] + #[error("Invalid Authentication Credentials")] InvalidCredentials, - // #[error("Authentication method was not valid")] + #[error("Authentication method was not valid")] InvalidAuthMethod, - // #[error("GitHub Token from incorrect Client ID")] + #[error("GitHub Token from incorrect Client ID")] InvalidClientId, - // #[error( - // "User email is already registered on Modrinth. Try 'Forgot password' to access your account." - // )] + #[error( + "User email is already registered on Modrinth. Try 'Forgot password' to access your account." + )] DuplicateUser, - // #[error("Invalid state sent, you probably need to get a new websocket")] + #[error("Invalid state sent, you probably need to get a new websocket")] SocketError, - // #[error("Invalid callback URL specified")] + #[error("Invalid callback URL specified")] Url, } @@ -88,8 +86,6 @@ i18n_enum!( Url! => "url_error", ); -labrinth_error_type!(AuthenticationError); - impl ResponseError for AuthenticationError { fn status_code(&self) -> StatusCode { match self { diff --git a/apps/labrinth/src/auth/oauth/errors.rs b/apps/labrinth/src/auth/oauth/errors.rs index 2e3a7efe63..6b6993b3e4 100644 --- a/apps/labrinth/src/auth/oauth/errors.rs +++ b/apps/labrinth/src/auth/oauth/errors.rs @@ -111,6 +111,7 @@ impl actix_web::ResponseError for OAuthError { } // TODO: Reference in an ApiError variant +// TODO: I18nEnum #[derive(thiserror::Error, Debug)] pub enum OAuthErrorType { #[error(transparent)] diff --git a/apps/labrinth/src/auth/templates/mod.rs b/apps/labrinth/src/auth/templates/mod.rs index 6a796ab58b..925b1ad92c 100644 --- a/apps/labrinth/src/auth/templates/mod.rs +++ b/apps/labrinth/src/auth/templates/mod.rs @@ -1,10 +1,6 @@ use crate::auth::AuthenticationError; use actix_web::http::StatusCode; -use actix_web::http::header::{ - AcceptLanguage, ContentLanguage, Header, LanguageTag, QualityItem, -}; -use actix_web::{HttpRequest, HttpResponse, ResponseError}; -use ariadne::i18n::I18nEnum; +use actix_web::{HttpResponse, ResponseError}; use std::fmt::{Debug, Display, Formatter}; pub struct Success<'a> { @@ -29,7 +25,6 @@ impl Success<'_> { pub struct ErrorPage { pub code: StatusCode, pub message: String, - pub language: LanguageTag, } impl Display for ErrorPage { @@ -44,25 +39,9 @@ impl Display for ErrorPage { } impl ErrorPage { - pub fn new(error: AuthenticationError, req: &HttpRequest) -> Self { - let language = AcceptLanguage::parse(req) - .ok() - .and_then(|x| x.preference().into_item()) - .unwrap_or_else(|| LanguageTag::parse("en").unwrap()); - let message = error.translated_message(language.as_str()); - Self { - code: error.status_code(), - message: message.into_owned(), - language, - } - } - pub fn render(&self) -> HttpResponse { HttpResponse::Ok() .append_header(("Content-Type", "text/html; charset=utf-8")) - .append_header(ContentLanguage(vec![QualityItem::max( - self.language.to_owned(), - )])) .body(self.to_string()) } } @@ -76,3 +55,12 @@ impl ResponseError for ErrorPage { self.render() } } + +impl From for ErrorPage { + fn from(item: AuthenticationError) -> Self { + ErrorPage { + code: item.status_code(), + message: item.to_string(), + } + } +} diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 664febb188..1f42f4d366 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -44,19 +44,19 @@ pub use version_item::DBVersion; #[derive(Error, Debug)] pub enum DatabaseError { - // #[error("Error while interacting with the database: {0}")] + #[error("Error while interacting with the database: {0}")] Database(#[from] sqlx::Error), - // #[error("Error while trying to generate random ID")] + #[error("Error while trying to generate random ID")] RandomId, - // #[error("Error while interacting with the cache: {0}")] + #[error("Error while interacting with the cache: {0}")] CacheError(#[from] redis::RedisError), - // #[error("Redis Pool Error: {0}")] + #[error("Redis Pool Error: {0}")] RedisPool(#[from] deadpool_redis::PoolError), - // #[error("Error while serializing with the cache: {0}")] + #[error("Error while serializing with the cache: {0}")] SerdeCacheError(#[from] serde_json::Error), - // #[error("Schema error: {0}")] + #[error("Schema error: {0}")] SchemaError(String), // TODO: Use I18nEnum instead of String - // #[error("Timeout when waiting for cache subscriber")] + #[error("Timeout when waiting for cache subscriber")] CacheTimeout, } @@ -66,7 +66,7 @@ i18n_enum!( Database(cause) => "sqlx", RandomId! => "random_id", CacheError(cause) => "cache", - RedisPool(cause) => "redix_pool", + RedisPool(cause) => "redis_pool", SerdeCacheError(cause) => "cache_serialization", SchemaError(cause) => "schema", CacheTimeout! => "cache_timeout", diff --git a/apps/labrinth/src/file_hosting/mod.rs b/apps/labrinth/src/file_hosting/mod.rs index d183b0ebab..f0f44e0dfb 100644 --- a/apps/labrinth/src/file_hosting/mod.rs +++ b/apps/labrinth/src/file_hosting/mod.rs @@ -12,13 +12,13 @@ pub use s3_host::{S3BucketConfig, S3Host}; #[derive(Error, Debug)] pub enum FileHostingError { // TODO: Use an I18nEnum instead of a String - // #[error("S3 error when {0}: {1}")] + #[error("S3 error when {0}: {1}")] S3Error(&'static str, s3::error::S3Error), - // #[error("File system error in file hosting: {0}")] + #[error("File system error in file hosting: {0}")] FileSystemError(#[from] std::io::Error), - // #[error("Invalid Filename")] + #[error("Invalid Filename")] InvalidFilename, } diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index f7b274ee74..89372ae0b9 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -13,7 +13,6 @@ use tracing::{info, warn}; extern crate clickhouse as clickhouse_crate; use clickhouse_crate::Client; use routes::error; -use rust_i18n::i18n; use util::cors::default_cors; use crate::background_task::update_versions; @@ -37,8 +36,6 @@ pub mod sync; pub mod util; pub mod validate; -i18n!(); - #[derive(Clone)] pub struct Pepper { pub pepper: String, diff --git a/apps/labrinth/src/models/error.rs b/apps/labrinth/src/models/error.rs index 72f317f610..b1b3e3d886 100644 --- a/apps/labrinth/src/models/error.rs +++ b/apps/labrinth/src/models/error.rs @@ -1,9 +1,31 @@ -use serde::{Deserialize, Serialize}; -use std::borrow::Cow; +use ariadne::i18n::{I18nEnum, TranslationData}; +use serde::Serialize; +use std::fmt::Display; /// An error returned by the API -#[derive(Serialize, Deserialize)] -pub struct ApiError<'a> { - pub error: &'a str, - pub description: Cow<'a, str>, +#[derive(Serialize)] +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/queue/moderation.rs b/apps/labrinth/src/queue/moderation.rs index 8c5967d04f..5d6ad6f83b 100644 --- a/apps/labrinth/src/queue/moderation.rs +++ b/apps/labrinth/src/queue/moderation.rs @@ -3,6 +3,7 @@ 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}; diff --git a/apps/labrinth/src/routes/error.rs b/apps/labrinth/src/routes/error.rs index 2f7afa4750..10112d1045 100644 --- a/apps/labrinth/src/routes/error.rs +++ b/apps/labrinth/src/routes/error.rs @@ -1,108 +1,108 @@ use crate::file_hosting::FileHostingError; +use crate::models::error::AsApiError; use actix_web::http::StatusCode; -use actix_web::http::header::AcceptLanguage; -use actix_web::http::header::{Header, LanguageTag}; -use actix_web::{HttpRequest, HttpResponse, ResponseError}; -use ariadne::i18n::I18nEnum; +use actix_web::{HttpResponse, ResponseError}; use ariadne::i18n_enum; #[derive(thiserror::Error, Debug)] pub enum ApiError { - // #[error("Environment Error")] + #[error("Environment Error")] Env(#[from] dotenvy::Error), - // #[error("Error while uploading file: {0}")] + #[error("Error while uploading file: {0}")] FileHosting(#[from] FileHostingError), - // #[error("Database Error: {0}")] + #[error("Database Error: {0}")] Database(#[from] crate::database::models::DatabaseError), - // #[error("Database Error: {0}")] + #[error("Database Error: {0}")] SqlxDatabase(#[from] sqlx::Error), - // #[error("Database Error: {0}")] + #[error("Database Error: {0}")] RedisDatabase(#[from] redis::RedisError), - // #[error("Clickhouse Error: {0}")] + #[error("Clickhouse Error: {0}")] Clickhouse(#[from] clickhouse::error::Error), // TODO: Use an I18nEnum instead of a String - // #[error("Internal server error: {0}")] + #[error("Internal server error: {0}")] Xml(String), - // #[error("Deserialization error: {0}")] + #[error("Deserialization error: {0}")] Json(#[from] serde_json::Error), - // #[error("Authentication Error: {0}")] + #[error("Authentication Error: {0}")] Authentication(#[from] crate::auth::AuthenticationError), // TODO: Use an I18nEnum instead of a String - // #[error("Authentication Error: {0}")] + #[error("Authentication Error: {0}")] CustomAuthentication(String), // TODO: Use an I18nEnum instead of a String - // #[error("Invalid Input: {0}")] + #[error("Invalid Input: {0}")] InvalidInput(String), // TODO: Perhaps remove this in favor of InvalidInput? - // #[error("Error while validating input: {0}")] + #[error("Error while validating input: {0}")] Validation(String), - // #[error("Search Error: {0}")] + #[error("Search Error: {0}")] Search(#[from] meilisearch_sdk::errors::Error), - // #[error("Indexing Error: {0}")] + #[error("Indexing Error: {0}")] Indexing(#[from] crate::search::indexing::IndexingError), // TODO: Use an I18nEnum instead of a String - // #[error("Payments Error: {0}")] + #[error("Payments Error: {0}")] Payments(String), // TODO: Use an I18nEnum instead of a String - // #[error("Discord Error: {0}")] + #[error("Discord Error: {0}")] Discord(String), - // #[error("Captcha Error. Try resubmitting the form.")] + #[error("Captcha Error. Try resubmitting the form.")] Turnstile, - // #[error("Error while decoding Base62: {0}")] + #[error("Error while decoding Base62: {0}")] Decoding(#[from] ariadne::ids::DecodingError), - // #[error("Image Parsing Error: {0}")] + #[error("Image Parsing Error: {0}")] ImageParse(#[from] image::ImageError), - // #[error("Password Hashing Error: {0}")] + #[error("Password Hashing Error: {0}")] PasswordHashing(#[from] argon2::password_hash::Error), - // #[error("{0}")] + #[error("{0}")] Mail(#[from] crate::auth::email::MailError), - // #[error("Error while rerouting request: {0}")] + #[error("Error while rerouting request: {0}")] Reroute(#[from] reqwest::Error), - // #[error("Unable to read Zip Archive: {0}")] + #[error("Unable to read Zip Archive: {0}")] Zip(#[from] zip::result::ZipError), - // #[error("IO Error: {0}")] + #[error("IO Error: {0}")] Io(#[from] std::io::Error), - // TODO: Add route not found - // #[error("Resource not found")] + #[error("Resource not found")] NotFound, + #[error("The requested route does not exist")] + RouteNotFound, + // TODO: Use an I18nEnum instead of a String - // #[error("Conflict: {0}")] + #[error("Conflict: {0}")] Conflict(String), - // #[error("External tax compliance API Error")] + #[error("External tax compliance API Error")] TaxComplianceApi, - // #[error( - // "You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining." - // )] + #[error( + "You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining." + )] RateLimitError(u128, u32), - // #[error("Error while interacting with payment processor: {0}")] + #[error("Error while interacting with payment processor: {0}")] Stripe(#[from] stripe::StripeError), } @@ -134,6 +134,7 @@ i18n_enum!( 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", @@ -166,6 +167,7 @@ impl ResponseError for ApiError { 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, @@ -179,60 +181,3 @@ impl ResponseError for ApiError { HttpResponse::build(self.status_code()).json(self.as_api_error()) } } - -#[macro_export] -macro_rules! labrinth_error_type { - ($error_enum:ty) => { - impl $error_enum { - pub fn as_api_error<'a>( - &self, - ) -> $crate::models::error::ApiError<'a> { - self.as_localized_api_error("en") - } - - pub fn as_localized_api_error<'a>( - &self, - language: &str, - ) -> crate::models::error::ApiError<'a> { - $crate::models::error::ApiError { - error: $crate::routes::error::error_id_for_error(self), - description: self.translated_message(language), - } - } - - pub fn localized_error_response( - &self, - req: &actix_web::HttpRequest, - ) -> actix_web::HttpResponse { - use actix_web::ResponseError; - use actix_web::http::header::{ContentLanguage, QualityItem}; - - let language = - $crate::routes::error::parse_accept_language(req); - let body = self.as_localized_api_error(language.as_str()); - - actix_web::HttpResponse::build(self.status_code()) - .append_header(ContentLanguage(vec![QualityItem::max( - language, - )])) - .json(body) - } - } - }; -} - -labrinth_error_type!(ApiError); - -pub fn error_id_for_error(error: &impl I18nEnum) -> &'static str { - let translation_id = error.translation_id(); - translation_id - .split_once('.') - .map_or(translation_id, |(base, _)| base) -} - -pub fn parse_accept_language(req: &HttpRequest) -> LanguageTag { - AcceptLanguage::parse(req) - .ok() - .and_then(|x| x.preference().into_item()) - .unwrap_or_else(|| LanguageTag::parse("en").unwrap()) -} diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 733043a563..fffd17a693 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -1252,7 +1252,7 @@ pub async fn auth_callback( } }; - res().await.map_err(|e| ErrorPage::new(e, &req)) + Ok(res().await?) } #[derive(Deserialize)] diff --git a/apps/labrinth/src/routes/maven.rs b/apps/labrinth/src/routes/maven.rs index 53de0a1d36..8572e45a77 100644 --- a/apps/labrinth/src/routes/maven.rs +++ b/apps/labrinth/src/routes/maven.rs @@ -12,7 +12,6 @@ use crate::queue::session::AuthQueue; use crate::routes::error::ApiError; use crate::{auth::get_user_from_headers, database}; use actix_web::{HttpRequest, HttpResponse, get, route, web}; -use ariadne::i18n::localized_labrinth_error; use sqlx::PgPool; use std::collections::HashSet; use yaserde::YaSerialize; @@ -70,7 +69,6 @@ pub struct MavenPom { } #[get("maven/modrinth/{id}/maven-metadata.xml")] -#[localized_labrinth_error] pub async fn maven_metadata( req: HttpRequest, params: web::Path<(String,)>, diff --git a/apps/labrinth/src/routes/not_found.rs b/apps/labrinth/src/routes/not_found.rs index f0da5015d5..00e55155ba 100644 --- a/apps/labrinth/src/routes/not_found.rs +++ b/apps/labrinth/src/routes/not_found.rs @@ -1,6 +1,6 @@ use crate::routes::error::ApiError; -use actix_web::{HttpRequest, HttpResponse}; +use actix_web::{HttpResponse, ResponseError}; -pub async fn not_found(req: HttpRequest) -> HttpResponse { - ApiError::NotFound.localized_error_response(&req) +pub async fn not_found() -> HttpResponse { + ApiError::RouteNotFound.error_response() } diff --git a/apps/labrinth/src/routes/v3/friends.rs b/apps/labrinth/src/routes/v3/friends.rs index 26c930486b..e75bb6213a 100644 --- a/apps/labrinth/src/routes/v3/friends.rs +++ b/apps/labrinth/src/routes/v3/friends.rs @@ -12,7 +12,6 @@ use crate::routes::internal::statuses::{ use crate::sync::friends::RedisFriendsMessage; use crate::sync::status::get_user_status; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; -use ariadne::i18n::localized_labrinth_error; use ariadne::networking::message::ServerToClientMessage; use chrono::Utc; use sqlx::PgPool; @@ -24,7 +23,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { } #[post("friend/{id}")] -#[localized_labrinth_error] pub async fn add_friend( req: HttpRequest, info: web::Path<(String,)>, diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 35a264e175..e2c5c21fff 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -8,7 +8,7 @@ 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::labrinth_error_type; +use crate::models::error::AsApiError; use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; @@ -27,7 +27,6 @@ use actix_multipart::{Field, Multipart}; use actix_web::http::StatusCode; use actix_web::web::{self, Data}; use actix_web::{HttpRequest, HttpResponse}; -use ariadne::i18n::I18nEnum; use ariadne::i18n_enum; use ariadne::ids::UserId; use ariadne::ids::base62_impl::to_base62; @@ -43,70 +42,70 @@ 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")] + #[error("Environment Error")] EnvError(#[from] dotenvy::Error), - // #[error("An unknown database error occurred")] + #[error("An unknown database error occurred")] SqlxDatabaseError(#[from] sqlx::Error), - // #[error("Database Error: {0}")] + #[error("Database Error: {0}")] DatabaseError(#[from] models::DatabaseError), - // #[error("Indexing Error: {0}")] + #[error("Indexing Error: {0}")] IndexingError(#[from] IndexingError), - // #[error("Error while parsing multipart payload: {0}")] + #[error("Error while parsing multipart payload: {0}")] MultipartError(#[from] actix_multipart::MultipartError), - // #[error("Error while parsing JSON: {0}")] + #[error("Error while parsing JSON: {0}")] SerDeError(#[from] serde_json::Error), - // #[error("Error while validating input: {0}")] + #[error("Error while validating input: {0}")] ValidationError(String), - // #[error("Error while uploading file: {0}")] + #[error("Error while uploading file: {0}")] FileHostingError(#[from] FileHostingError), - // #[error("Error while validating uploaded file: {0}")] + #[error("Error while validating uploaded file: {0}")] FileValidationError(#[from] crate::validate::ValidationError), // TODO: Use an I18nEnum instead of a String - // #[error("{}", .0)] + #[error("{}", .0)] MissingValueError(String), - // #[error("Invalid format for image: {0}")] + #[error("Invalid format for image: {0}")] InvalidIconFormat(ApiError), // TODO: Use an I18nEnum instead of a String - // #[error("Error with multipart data: {0}")] + #[error("Error with multipart data: {0}")] InvalidInput(String), - // #[error("Invalid loader: {0}")] + #[error("Invalid loader: {0}")] InvalidLoader(String), - // #[error("Invalid category: {0}")] + #[error("Invalid category: {0}")] InvalidCategory(String), - // #[error("Invalid file type for version file: {0}")] + #[error("Invalid file type for version file: {0}")] InvalidFileType(String), - // #[error("Slug is already taken!")] + #[error("Slug is already taken!")] SlugCollision, - // #[error("Authentication Error: {0}")] + #[error("Authentication Error: {0}")] Unauthorized(#[from] AuthenticationError), // TODO: Use an I18nEnum instead of a String - // #[error("Authentication Error: {0}")] + #[error("Authentication Error: {0}")] CustomAuthenticationError(String), - // #[error("Image Parsing Error: {0}")] + #[error("Image Parsing Error: {0}")] ImageError(#[from] ImageError), } @@ -134,8 +133,6 @@ i18n_enum!( ImageError(cause) => "invalid_image", ); -labrinth_error_type!(CreateError); - impl actix_web::ResponseError for CreateError { fn status_code(&self) -> StatusCode { match self { diff --git a/apps/labrinth/src/util/ratelimit.rs b/apps/labrinth/src/util/ratelimit.rs index 4a978dfde6..1629b9aa92 100644 --- a/apps/labrinth/src/util/ratelimit.rs +++ b/apps/labrinth/src/util/ratelimit.rs @@ -2,7 +2,7 @@ use crate::database::redis::RedisPool; use crate::routes::error::ApiError; use crate::util::env::parse_var; use actix_web::{ - Error, + Error, ResponseError, body::{EitherBody, MessageBody}, dev::{ServiceRequest, ServiceResponse}, middleware::Next, @@ -187,7 +187,7 @@ pub async fn rate_limit_middleware( decision.retry_after_ms.unwrap_or(0) as u128, decision.limit, ) - .localized_error_response(req.request()); + .error_response(); // Add rate limit headers let headers = response.headers_mut(); @@ -228,7 +228,7 @@ pub async fn rate_limit_middleware( let response = ApiError::CustomAuthentication( "Unable to obtain user IP address!".to_string(), ) - .localized_error_response(req.request()); + .error_response(); Ok(req.into_response(response.map_into_right_body())) } diff --git a/packages/ariadne-macros/Cargo.toml b/packages/ariadne-macros/Cargo.toml deleted file mode 100644 index e15d5eafba..0000000000 --- a/packages/ariadne-macros/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "ariadne-macros" -version = "0.1.0" -edition.workspace = true - -[lib] -proc-macro = true - -[dependencies] -syn.workspace = true -quote.workspace = true -proc-macro2.workspace = true - -[features] -labrinth = [] - -[lints] -workspace = true diff --git a/packages/ariadne-macros/src/lib.rs b/packages/ariadne-macros/src/lib.rs deleted file mode 100644 index 9efd6c6d55..0000000000 --- a/packages/ariadne-macros/src/lib.rs +++ /dev/null @@ -1,47 +0,0 @@ -#[cfg(feature = "labrinth")] -use proc_macro::TokenStream; - -// This exists purely to work around https://github.com/actix/actix-web/issues/2925 -#[cfg(feature = "labrinth")] -#[proc_macro_attribute] -pub fn localized_labrinth_error( - _args: TokenStream, - input: TokenStream, -) -> TokenStream { - use quote::quote; - use syn::spanned::Spanned; - use syn::{Error, FnArg, ItemFn, PatType, parse_macro_input, parse_quote}; - - let function = parse_macro_input!(input as ItemFn); - - let mut adjusted_sig = function.sig.clone(); - adjusted_sig.output = parse_quote! { - -> ::actix_web::HttpResponse - }; - - let vis = function.vis; - let return_type = function.sig.output; - let body = function.block; - let Some(FnArg::Typed(PatType { pat: req, .. })) = - function.sig.inputs.first() - else { - return Error::new( - function.sig.inputs.span(), - "Expected first parameter to be HttpRequest", - ) - .to_compile_error() - .into(); - }; - - quote! { - #vis #adjusted_sig { - let mut handler = async || #return_type #body; - let result = handler().await; - match result { - Ok(resp) => resp, - Err(e) => e.localized_error_response(&#req), - } - } - } - .into() -} diff --git a/packages/ariadne/Cargo.toml b/packages/ariadne/Cargo.toml index 7a001a1578..66c7e120cf 100644 --- a/packages/ariadne/Cargo.toml +++ b/packages/ariadne/Cargo.toml @@ -13,12 +13,6 @@ rand.workspace = true either.workspace = true chrono = { workspace = true, features = ["serde"] } serde_cbor.workspace = true -rust-i18n.workspace = true - -ariadne-macros.workspace = true - -[features] -labrinth = ["ariadne-macros/labrinth"] [lints] workspace = true diff --git a/packages/ariadne/src/i18n.rs b/packages/ariadne/src/i18n.rs index 7d70fcf007..6fbde55432 100644 --- a/packages/ariadne/src/i18n.rs +++ b/packages/ariadne/src/i18n.rs @@ -1,7 +1,5 @@ -use std::borrow::Cow; - -#[cfg(feature = "labrinth")] -pub use ariadne_macros::localized_labrinth_error; +use serde::Serialize; +use std::collections::HashMap; pub trait I18nEnum { const ROOT_TRANSLATION_ID: &'static str; @@ -10,7 +8,18 @@ pub trait I18nEnum { fn full_translation_id(&self) -> &'static str; - fn translated_message<'a>(&self, locale: &str) -> Cow<'a, str>; + fn translation_data(&self) -> TranslationData; +} + +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum TranslationData { + Literal(String), + Translatable { + key: &'static str, + #[serde(skip_serializing_if = "HashMap::is_empty")] + values: HashMap<&'static str, TranslationData>, + }, } #[macro_export] @@ -29,17 +38,11 @@ macro_rules! i18n_enum { } fn full_translation_id(&self) -> &'static str { - concat!($root_key, ".", $key) + ::core::concat!($root_key, ".", $key) } - fn translated_message<'a>(&self, locale: &str) -> ::std::borrow::Cow<'a, str> { - ::rust_i18n::t!(concat!($root_key, ".", $key), locale = locale) - } - } - - impl ::std::fmt::Display for $for_enum { - fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { - f.write_str(&self.translated_message("en")) + fn translation_data(&self) -> $crate::i18n::TranslationData { + $crate::__i18n_enum_variant_values!($root_key, $key, !) } } }; @@ -62,42 +65,36 @@ macro_rules! i18n_enum { fn full_translation_id(&self) -> &'static str { use $for_enum::*; match self { - $($crate::__i18n_enum_variant_parameters_no_store!($variant_name, $variant_pat) => concat!($root_key, ".", $key),)* + $($crate::__i18n_enum_variant_parameters_no_store!($variant_name, $variant_pat) => ::core::concat!($root_key, ".", $key),)* } } - fn translated_message<'a>(&self, locale: &str) -> ::std::borrow::Cow<'a, str> { + fn translation_data(&self) -> $crate::i18n::TranslationData { trait __TranslatableEnum { - fn __maybe_translate<'a>(&self, locale: &str) -> ::std::borrow::Cow<'a, str>; + fn __maybe_translate(&self) -> $crate::i18n::TranslationData; } impl __TranslatableEnum for T { - fn __maybe_translate<'a>(&self, locale: &str) -> ::std::borrow::Cow<'a, str> { - $crate::i18n::I18nEnum::translated_message(self, locale) + fn __maybe_translate(&self) -> $crate::i18n::TranslationData { + $crate::i18n::I18nEnum::translation_data(self) } } trait __NonTranslatableValue { - fn __maybe_translate<'a>(&self, _locale: &str) -> ::std::borrow::Cow<'a, str>; + fn __maybe_translate(&self) -> $crate::i18n::TranslationData; } impl __NonTranslatableValue for &T { - fn __maybe_translate<'a>(&self, _locale: &str) -> ::std::borrow::Cow<'a, str> { - ::std::borrow::Cow::Owned(::std::string::ToString::to_string(self)) + 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, locale, $variant_pat), + $crate::__i18n_enum_variant_values!($root_key, $key, $variant_pat), )* } } } - - impl ::std::fmt::Display for $for_enum { - fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { - f.write_str(&$crate::i18n::I18nEnum::translated_message(self, "en")) - } - } }; } @@ -132,20 +129,28 @@ macro_rules! __i18n_enum_variant_parameters { #[macro_export] #[doc(hidden)] macro_rules! __i18n_enum_variant_values { - ($root_key:literal, $key:literal, $locale:ident, !) => { - ::rust_i18n::t!(concat!($root_key, ".", $key), locale = $locale) + ($root_key:literal, $key:literal, !) => { + $crate::i18n::TranslationData::Translatable { + key: ::core::concat!($root_key, ".", $key), + values: ::std::collections::HashMap::new(), + } }; - ($root_key:literal, $key:literal, $locale:ident, (..)) => { - ::rust_i18n::t!(concat!($root_key, ".", $key), locale = $locale) + ($root_key:literal, $key:literal, (..)) => { + $crate::__i18n_enum_variant_values!($root_key, $key, !) }; - ($root_key:literal, $key:literal, $locale:ident, {..}) => { - ::rust_i18n::t!(concat!($root_key, ".", $key), locale = $locale) + ($root_key:literal, $key:literal, {..}) => { + $crate::__i18n_enum_variant_values!($root_key, $key, !) }; - ($root_key:literal, $key:literal, $locale:ident, ($($field:ident),*)) => { - ::rust_i18n::t!(concat!($root_key, ".", $key), locale = $locale $(, $field = $field.__maybe_translate($locale))*) + ($root_key:literal, $key:literal, ($($field:ident),*)) => { + $crate::i18n::TranslationData::Translatable { + key: ::core::concat!($root_key, ".", $key), + values: ::std::collections::HashMap::from([ + $((::core::stringify!($field), $field.__maybe_translate()),)* + ]), + } }; - ($root_key:literal, $key:literal, $locale:ident, {$($field:ident),*}) => { - ::rust_i18n::t!(concat!($root_key, ".", $key), locale = $locale $(, $field = $field.__maybe_translate($locale))*) + ($root_key:literal, $key:literal, {$($field:ident),*}) => { + $crate::__i18n_enum_variant_values!($root_key, $key, ($($field),*)) }; } @@ -153,38 +158,7 @@ macro_rules! __i18n_enum_variant_values { #[doc(hidden)] pub mod test { use super::*; - - pub struct TestingBackend; - - impl rust_i18n::Backend for TestingBackend { - fn available_locales(&self) -> Vec<&str> { - vec!["en", "ja"] - } - - fn translate(&self, locale: &str, key: &str) -> Option<&str> { - let value = match locale { - "en" => match key { - "unit_translatable.unit" => "Unit Translatable", - "base.unit" => "Unit", - "base.tuple" => "Tuple: %{value}", - "base.translatable_tuple" => "Translatable Tuple: %{unit}", - "base.named" => "Named: %{subfield}", - _ => panic!("No translation for {key}"), - }, - "ja" => match key { - "unit_translatable.unit" => "この単位は翻訳できる", - "base.unit" => "単位", - "base.tuple" => "組: %{value}", - "base.translatable_tuple" => { - "この組は翻訳できる: %{unit}" - } - _ => self.translate("en", key)?, - }, - _ => panic!("No translations for {locale}"), - }; - Some(value) - } - } + use serde_json::json; struct UnitTranslatable; @@ -210,38 +184,57 @@ pub mod test { Named { subfield } => "named", ); - fn assert_i18n_eq(x: impl I18nEnum, lang: &str, should_be: &str) { - assert_eq!(x.translated_message(lang), should_be); + 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_en() { - assert_i18n_eq(UnitTranslatable, "en", "Unit Translatable"); - assert_i18n_eq(TestEnum::Unit, "en", "Unit"); - assert_i18n_eq(TestEnum::Tuple("hello"), "en", "Tuple: hello"); + 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), - "en", - "Translatable Tuple: Unit Translatable", + json!({ + "key": "base.translatable_tuple", + "values": { + "unit": { + "key": "unit_translatable.unit", + }, + }, + }), ); assert_i18n_eq( TestEnum::Named { subfield: "Subfield", }, - "en", - "Named: Subfield", - ); - } - - #[test] - fn test_ja() { - assert_i18n_eq(UnitTranslatable, "ja", "この単位は翻訳できる"); - assert_i18n_eq(TestEnum::Unit, "ja", "単位"); - assert_i18n_eq(TestEnum::Tuple("こんにちは"), "ja", "組: こんにちは"); - assert_i18n_eq( - TestEnum::TranslatableTuple(UnitTranslatable), - "ja", - "この組は翻訳できる: この単位は翻訳できる", + json!({ + "key": "base.named", + "values": { + "subfield": "Subfield", + } + }), ); } } diff --git a/packages/ariadne/src/lib.rs b/packages/ariadne/src/lib.rs index 3e7830687d..9335e035df 100644 --- a/packages/ariadne/src/lib.rs +++ b/packages/ariadne/src/lib.rs @@ -3,6 +3,3 @@ pub mod ids; pub mod networking; pub mod users; pub mod versions; - -#[cfg(test)] -i18n::i18n!(backend = i18n::test::TestingBackend); From e0299968c4ecf37e8e5845b5b91d890ba46cd40d Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Wed, 10 Sep 2025 11:20:43 -0500 Subject: [PATCH 11/43] Revert some accidental added blank lines --- apps/labrinth/src/auth/mod.rs | 13 --------- .../src/routes/v3/project_creation.rs | 27 +++---------------- 2 files changed, 3 insertions(+), 37 deletions(-) diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs index f527db2b03..f16cdbae43 100644 --- a/apps/labrinth/src/auth/mod.rs +++ b/apps/labrinth/src/auth/mod.rs @@ -24,45 +24,32 @@ use thiserror::Error; pub enum AuthenticationError { #[error("Environment Error")] Env(#[from] dotenvy::Error), - #[error("An unknown database error occurred: {0}")] Sqlx(#[from] sqlx::Error), - #[error("Database Error: {0}")] Database(#[from] crate::database::models::DatabaseError), - #[error("Error while parsing JSON: {0}")] SerDe(#[from] serde_json::Error), - #[error("Error while communicating to external provider")] Reqwest(#[from] reqwest::Error), - #[error("Error uploading user profile picture")] FileHosting(#[from] FileHostingError), - #[error("Error while decoding PAT: {0}")] Decoding(#[from] ariadne::ids::DecodingError), - #[error("{0}")] Mail(#[from] email::MailError), - #[error("Invalid Authentication Credentials")] InvalidCredentials, - #[error("Authentication method was not valid")] InvalidAuthMethod, - #[error("GitHub Token from incorrect Client ID")] InvalidClientId, - #[error( "User email is already registered on Modrinth. Try 'Forgot password' to access your account." )] DuplicateUser, - #[error("Invalid state sent, you probably need to get a new websocket")] SocketError, - #[error("Invalid callback URL specified")] Url, } diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index e2c5c21fff..a7738818a7 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -50,61 +50,40 @@ pub fn config(cfg: &mut web::ServiceConfig) { 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), - - // TODO: Use an I18nEnum instead of a String #[error("{}", .0)] - MissingValueError(String), - + MissingValueError(String), // TODO: Use an I18nEnum instead of a String #[error("Invalid format for image: {0}")] InvalidIconFormat(ApiError), - - // TODO: Use an I18nEnum instead of a String #[error("Error with multipart data: {0}")] - InvalidInput(String), - + InvalidInput(String), // TODO: Use an I18nEnum instead of a 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), - - // TODO: Use an I18nEnum instead of a String #[error("Authentication Error: {0}")] - CustomAuthenticationError(String), - + CustomAuthenticationError(String), // TODO: Use an I18nEnum instead of a String #[error("Image Parsing Error: {0}")] ImageError(#[from] ImageError), } From f863f6c9aca8ab5cff96fb9f0262774b9ceb1cd5 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Wed, 10 Sep 2025 14:46:58 -0500 Subject: [PATCH 12/43] Add I18nEnum implementations for more error types --- apps/labrinth/src/auth/email/mod.rs | 10 ++++ apps/labrinth/src/auth/oauth/errors.rs | 70 ++++++++++++------------ apps/labrinth/src/database/models/ids.rs | 7 +++ apps/labrinth/src/search/indexing/mod.rs | 20 +++++-- apps/labrinth/src/search/mod.rs | 35 ++++++------ apps/labrinth/src/validate/mod.rs | 26 ++++++--- packages/ariadne/src/i18n.rs | 36 +++++++++++- 7 files changed, 139 insertions(+), 65 deletions(-) diff --git a/apps/labrinth/src/auth/email/mod.rs b/apps/labrinth/src/auth/email/mod.rs index 914a742714..c4e0c06909 100644 --- a/apps/labrinth/src/auth/email/mod.rs +++ b/apps/labrinth/src/auth/email/mod.rs @@ -1,3 +1,4 @@ +use ariadne::i18n_enum; use lettre::message::Mailbox; use lettre::message::header::ContentType; use lettre::transport::smtp::authentication::Credentials; @@ -18,6 +19,15 @@ pub enum MailError { Smtp(#[from] lettre::transport::smtp::Error), } +i18n_enum!( + MailError, + root_key: "error.mail", + Env(..) => "environment", + Mail(cause) => "email", + Address(cause) => "address", + Smtp(cause) => "smtp", +); + pub fn send_email_raw( to: String, subject: String, diff --git a/apps/labrinth/src/auth/oauth/errors.rs b/apps/labrinth/src/auth/oauth/errors.rs index 6b6993b3e4..be38c609f2 100644 --- a/apps/labrinth/src/auth/oauth/errors.rs +++ b/apps/labrinth/src/auth/oauth/errors.rs @@ -1,8 +1,10 @@ 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)] @@ -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,21 +112,17 @@ 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().into(), - }) + HttpResponse::build(self.status_code()).json(self.as_api_error()) } } } // TODO: Reference in an ApiError variant -// TODO: I18nEnum #[derive(thiserror::Error, Debug)] 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, }, @@ -156,6 +162,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: "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()) @@ -167,29 +195,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/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs index 795862cef2..532e412ed2 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; @@ -107,6 +108,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/search/indexing/mod.rs b/apps/labrinth/src/search/indexing/mod.rs index 1ecb70ad79..8977c1229f 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: "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 fe7e208216..0f688fbe88 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; @@ -14,7 +15,6 @@ use thiserror::Error; pub mod indexing; -// TODO: Migrate to I18nEnum #[derive(Error, Debug)] pub enum SearchError { #[error("MeiliSearch Error: {0}")] @@ -31,6 +31,17 @@ pub enum SearchError { InvalidIndex(String), } +i18n_enum!( + SearchError, + root_key: "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 { @@ -44,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()) } } @@ -127,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)] @@ -166,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/validate/mod.rs b/apps/labrinth/src/validate/mod.rs index 6304b61ddc..c5a3c1b625 100644 --- a/apps/labrinth/src/validate/mod.rs +++ b/apps/labrinth/src/validate/mod.rs @@ -17,6 +17,7 @@ use crate::validate::rift::RiftValidator; use crate::validate::shader::{ CanvasShaderValidator, CoreShaderValidator, ShaderValidator, }; +use ariadne::i18n_enum; use bytes::Bytes; use chrono::{DateTime, Utc}; use std::io::{self, Cursor}; @@ -41,19 +42,30 @@ 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: "error.file_validation", + Zip(cause) => "zip", + Io(cause) => "io", + SerDe(cause) => "serialization", + InvalidInput(cause) => "invalid_input", + Blocking(..) => "blocking", + Database(..) => "database", +); + #[derive(Eq, PartialEq, Debug)] pub enum ValidationResult { /// File should be marked as primary with pack file data @@ -64,7 +76,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(&'static str), // TODO: Use an I18nEnum } impl ValidationResult { @@ -96,7 +108,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 +184,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, @@ -314,7 +326,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()) diff --git a/packages/ariadne/src/i18n.rs b/packages/ariadne/src/i18n.rs index 6fbde55432..8a7ae81410 100644 --- a/packages/ariadne/src/i18n.rs +++ b/packages/ariadne/src/i18n.rs @@ -24,12 +24,29 @@ pub enum TranslationData { #[macro_export] macro_rules! i18n_enum { + (transparent $for_enum:ty[$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) + } + } + }; + ( $for_enum:ty, root_key: $root_key:literal, _ => $key:literal, ) => { - #[allow(unused_variables)] // Rust doesn't see the variables from $variant get used for some rason impl $crate::i18n::I18nEnum for $for_enum { const ROOT_TRANSLATION_ID: &'static str = $root_key; @@ -104,6 +121,9 @@ macro_rules! __i18n_enum_variant_parameters_no_store { ($variant_name:ident, !) => { $variant_name }; + ($variant_name:ident, (transparent $_:ident)) => { + $variant_name(_) + }; ($variant_name:ident, ($($_:tt)+)) => { $variant_name(..) }; @@ -118,6 +138,9 @@ macro_rules! __i18n_enum_variant_parameters { ($variant_name:ident, !) => { $variant_name }; + ($variant_name:ident, (transparent $field:ident)) => { + $variant_name($field) + }; ($variant_name:ident, ($($field:tt)+)) => { $variant_name($($field)+) }; @@ -141,6 +164,9 @@ macro_rules! __i18n_enum_variant_values { ($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, ($($field:ident),*)) => { $crate::i18n::TranslationData::Translatable { key: ::core::concat!($root_key, ".", $key), @@ -173,6 +199,7 @@ pub mod test { Tuple(&'static str), TranslatableTuple(UnitTranslatable), Named { subfield: &'static str }, + DirectUnit(UnitTranslatable), } i18n_enum!( @@ -182,6 +209,7 @@ pub mod test { 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) { @@ -236,5 +264,11 @@ pub mod test { } }), ); + assert_i18n_eq( + TestEnum::DirectUnit(UnitTranslatable), + json!({ + "key": "unit_translatable.unit", + }), + ) } } From 48b1fb9b111d20ad08a681c6e9f0321cdca17f9f Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Wed, 10 Sep 2025 21:15:14 -0500 Subject: [PATCH 13/43] Create ariadne-extract to extract i18n data --- .idea/code.iml | 4 +- Cargo.lock | 13 + Cargo.toml | 7 +- apps/app/Cargo.toml | 2 +- apps/ariadne-extract/Cargo.toml | 18 ++ apps/ariadne-extract/src/error.rs | 14 + apps/ariadne-extract/src/extractor.rs | 437 ++++++++++++++++++++++++++ apps/ariadne-extract/src/main.rs | 52 +++ apps/daedalus_client/Cargo.toml | 2 +- apps/labrinth/Cargo.toml | 4 +- packages/app-lib/Cargo.toml | 2 +- packages/ariadne/Cargo.toml | 2 +- packages/ariadne/src/i18n.rs | 59 ++-- packages/daedalus/Cargo.toml | 2 +- 14 files changed, 577 insertions(+), 41 deletions(-) create mode 100644 apps/ariadne-extract/Cargo.toml create mode 100644 apps/ariadne-extract/src/error.rs create mode 100644 apps/ariadne-extract/src/extractor.rs create mode 100644 apps/ariadne-extract/src/main.rs diff --git a/.idea/code.iml b/.idea/code.iml index 311d1ac66a..acbaa11e5d 100644 --- a/.idea/code.iml +++ b/.idea/code.iml @@ -11,10 +11,10 @@ - + - \ No newline at end of file + diff --git a/Cargo.lock b/Cargo.lock index 265e4b674f..6e3d629a52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -487,6 +487,19 @@ dependencies = [ "uuid 1.17.0", ] +[[package]] +name = "ariadne-extract" +version = "0.1.0" +dependencies = [ + "clap", + "proc-macro2", + "serde", + "serde_json", + "syn 2.0.106", + "thiserror 2.0.12", + "walkdir", +] + [[package]] name = "arrayvec" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index d289626272..e842ae82a9 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" @@ -102,7 +103,6 @@ proc-macro2 = "1.0.101" prometheus = "0.14.0" quartz_nbt = "0.2.9" quick-xml = "0.38.1" -quote = "1.0.40" rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9 rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9 redis = "0.32.4" @@ -126,7 +126,7 @@ 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" @@ -171,6 +171,7 @@ 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" 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/Cargo.toml b/apps/ariadne-extract/Cargo.toml new file mode 100644 index 0000000000..0cfa88816c --- /dev/null +++ b/apps/ariadne-extract/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ariadne-extract" +version = "0.1.0" +edition.workspace = true + +[dependencies] +clap.workspace = true +thiserror.workspace = true + +serde_json.workspace = true +serde.workspace = true + +syn = { workspace = true, features = ["visit"] } +proc-macro2 = { workspace = true, features = ["span-locations"] } +walkdir.workspace = true + +[lints] +workspace = true 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..cff8e92947 --- /dev/null +++ b/apps/ariadne-extract/src/extractor.rs @@ -0,0 +1,437 @@ +use crate::error::Result; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; +use std::fmt::Write; +use std::fs; +use std::path::{Path, PathBuf}; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +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(Default, Debug, Serialize, Deserialize)] +pub struct TranslationEntry { + pub message: String, +} + +impl TranslationEntry { + pub fn new(message: String) -> Self { + Self { message } + } +} + +pub struct ParseError { + pub error: syn::Error, + pub file_path: PathBuf, + pub line: String, +} + +impl ParseError { + pub fn pretty_print(&self) -> String { + let mut result = String::new(); + let span = self.error.span(); + let span_start = span.start(); + let span_end = span.end(); + let _ = writeln!( + result, + "Error at {}:{}:{} through {}:{}", + self.file_path.display(), + span_start.line, + span_start.column + 1, + span_end.line, + span_end.column + 1, + ); + let _ = writeln!(result, " {}", self.error); + let _ = writeln!(result, " {}", self.line); + result.push_str(&" ".repeat(span_start.column + 4)); + result.push('^'); + if span_end.line == span_start.line { + result.push_str(&"~".repeat(span_end.column - span_start.column)); + } else { + result + .push_str(&"~".repeat(self.line.len() - span_start.column - 1)); + result.push('v'); + } + result + } +} + +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).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, + path: file.path().to_path_buf(), + lines: file_contents.lines().map(str::to_string).collect(), + 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)) + } +} + +struct FileExtractor<'a> { + extractor: &'a mut Extractor, + path: PathBuf, + lines: Vec, + enum_messages: HashMap>, +} + +impl FileExtractor<'_> { + fn add_error(&mut self, error: syn::Error) { + for error in error { + self.extractor.errors.push(ParseError { + file_path: self.path.clone(), + line: self.lines[error.span().start().line - 1].clone(), + 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(); + for variant in &i.variants { + let error_message = variant.attrs.iter().find_map(|x| { + if !x.path().is_ident("error") { + return None; + } + x.parse_args().ok() + }); + if let Some(error_message) = error_message { + variants.insert(variant.ident.clone(), error_message); + } + } + if !variants.is_empty() { + 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 messages = self.enum_messages.get(&for_enum); + let root_key = root_key.value(); + for variant in variants { + if variant.transparent.is_some() { + continue; + } + self.extractor.output.insert( + format!("{}.{}", root_key, variant.key.value()), + messages + .and_then(|x| x.get(&variant.variant_name)) + .map(LitStr::value) + .map(|x| variant.transform_format_string(x)) + .map(TranslationEntry::new) + .unwrap_or_default(), + ); + } + } + } + } +} + +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 fields_type; + let fields = if input.peek(Token![!]) { + fields_type = FieldsType::Unit; + input.parse::()?; + Punctuated::new() + } else { + let content; + if input.peek(token::Brace) { + fields_type = FieldsType::Named; + braced!(content in input); + if content.peek(Token![..]) { + content.parse::()?; + Punctuated::new() + } else { + content.parse_terminated(Ident::parse, Token![,])? + } + } else { + fields_type = FieldsType::Tuple; + parenthesized!(content in input); + if content.peek(Token![..]) { + content.parse::()?; + Punctuated::new() + } else if content.peek(kw::transparent) { + transparent = Some(content.parse()?); + content.parse::()?; + Punctuated::new() + } else { + content.parse_terminated(Ident::parse, Token![,])? + } + } + }; + + 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) -> String { + if !format_string.contains('{') || self.fields.is_empty() { + return format_string; + } + + let name_transforms = if matches!(self.fields_type, FieldsType::Tuple) { + self.fields.iter().map(Ident::to_string).collect() + } else { + vec![] + }; + + let mut result = String::new(); + + let mut prev_push_index = 0; + let mut format_start = None; + let mut extra_format_layers = 0usize; + let mut format_index = 0; + let mut iter = format_string.chars().enumerate(); + while let Some((index, char)) = iter.next() { + if char == '\'' { + result.push_str(&format_string[prev_push_index..index + 1]); + result.push('\''); + prev_push_index = index + 1; + } + if char == '{' { + if format_start.is_some() { + extra_format_layers += 1; + continue; + } + result.push_str(&format_string[prev_push_index..index]); + if matches!(iter.next(), Some((_, '{'))) { + result.push_str("'{'"); + prev_push_index = index + 2; + } else { + result.push('{'); + prev_push_index = index + 1; + format_start = Some(index + 1); + } + continue; + } + if char == '}' { + 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 = name_transforms + .get(format_index) + .map_or(format_variable, String::as_str); + result.push_str(format_variable); + format_index += 1; + format_start = None; + prev_push_index = index; + continue; + } else if matches!(iter.next(), Some((_, '}'))) { + result.push_str(&format_string[prev_push_index..index]); + result.push_str("'}'"); + prev_push_index = index + 2; + continue; + } + } + } + result.push_str(&format_string[prev_push_index..]); + + result + } +} + +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..5d4737ebc3 --- /dev/null +++ b/apps/ariadne-extract/src/main.rs @@ -0,0 +1,52 @@ +mod error; +mod extractor; + +use crate::extractor::Extractor; +use clap::Parser; +use error::Result; +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() { + if let Err(err) = run(Args::parse()) { + 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() { + eprintln!("{}", error.pretty_print()); + } + exit(extractor.errors().len().try_into().unwrap_or(i32::MAX)); + } + + 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(()) +} 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/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index c0dc09a90b..ab19dbab3a 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -39,7 +39,7 @@ reqwest = { workspace = true, features = ["http2", "rustls-tls-webpki-roots", "j 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"] } @@ -130,7 +130,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/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index dc01dd23d8..2345e8d9d7 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/src/i18n.rs b/packages/ariadne/src/i18n.rs index 8a7ae81410..0b02bf67cb 100644 --- a/packages/ariadne/src/i18n.rs +++ b/packages/ariadne/src/i18n.rs @@ -22,9 +22,10 @@ pub enum TranslationData { }, } +// The extractor in ariadne_extract::extractor needs to be kept up-to-date with this macro definition #[macro_export] macro_rules! i18n_enum { - (transparent $for_enum:ty[$field:ident: $field_type:ty]) => { + (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; @@ -43,29 +44,7 @@ macro_rules! i18n_enum { }; ( - $for_enum:ty, - root_key: $root_key:literal, - _ => $key:literal, - ) => { - impl $crate::i18n::I18nEnum for $for_enum { - const ROOT_TRANSLATION_ID: &'static str = $root_key; - - fn translation_id(&self) -> &'static str { - $key - } - - fn full_translation_id(&self) -> &'static str { - ::core::concat!($root_key, ".", $key) - } - - fn translation_data(&self) -> $crate::i18n::TranslationData { - $crate::__i18n_enum_variant_values!($root_key, $key, !) - } - } - }; - - ( - $for_enum:ty, + $for_enum:ident, root_key: $root_key:literal, $($variant_name:ident$variant_pat:tt => $key:literal,)* ) => { @@ -185,20 +164,42 @@ macro_rules! __i18n_enum_variant_values { pub mod test { use super::*; use serde_json::json; + use thiserror::Error; + #[derive(Debug, Error)] + #[error("Unit Translatable")] struct UnitTranslatable; - i18n_enum!( - UnitTranslatable, - root_key: "unit_translatable", - _ => "unit", - ); + 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: self.full_translation_id(), + values: HashMap::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), } 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 From 0aeb0dc9165bcf5c7190c29d44033641e1c946c9 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 11 Sep 2025 09:47:57 -0500 Subject: [PATCH 14/43] Improve ariadne-extract errors with miette --- Cargo.lock | 111 ++++++++++++++++++++++++++ Cargo.toml | 2 + apps/ariadne-extract/Cargo.toml | 2 + apps/ariadne-extract/src/extractor.rs | 63 +++------------ apps/ariadne-extract/src/main.rs | 7 +- 5 files changed, 133 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6e3d629a52..fe8d094e2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -492,10 +492,12 @@ name = "ariadne-extract" version = "0.1.0" dependencies = [ "clap", + "miette", "proc-macro2", "serde", "serde_json", "syn 2.0.106", + "syn-miette", "thiserror 2.0.12", "walkdir", ] @@ -968,6 +970,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" @@ -4106,6 +4117,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" @@ -4849,6 +4866,36 @@ 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", + "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" @@ -5622,6 +5669,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" @@ -8380,6 +8433,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" @@ -8413,6 +8487,17 @@ dependencies = [ "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" @@ -9021,6 +9106,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" @@ -9801,6 +9906,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" diff --git a/Cargo.toml b/Cargo.toml index e842ae82a9..3f51db99ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,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 } @@ -139,6 +140,7 @@ 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" diff --git a/apps/ariadne-extract/Cargo.toml b/apps/ariadne-extract/Cargo.toml index 0cfa88816c..ab43a67253 100644 --- a/apps/ariadne-extract/Cargo.toml +++ b/apps/ariadne-extract/Cargo.toml @@ -12,6 +12,8 @@ serde.workspace = true syn = { workspace = true, features = ["visit"] } proc-macro2 = { workspace = true, features = ["span-locations"] } +syn-miette.workspace = true +miette.workspace = true walkdir.workspace = true [lints] diff --git a/apps/ariadne-extract/src/extractor.rs b/apps/ariadne-extract/src/extractor.rs index cff8e92947..e9d6fb7779 100644 --- a/apps/ariadne-extract/src/extractor.rs +++ b/apps/ariadne-extract/src/extractor.rs @@ -1,9 +1,8 @@ use crate::error::Result; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; -use std::fmt::Write; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; use syn::visit::Visit; @@ -24,46 +23,10 @@ impl TranslationEntry { } } -pub struct ParseError { - pub error: syn::Error, - pub file_path: PathBuf, - pub line: String, -} - -impl ParseError { - pub fn pretty_print(&self) -> String { - let mut result = String::new(); - let span = self.error.span(); - let span_start = span.start(); - let span_end = span.end(); - let _ = writeln!( - result, - "Error at {}:{}:{} through {}:{}", - self.file_path.display(), - span_start.line, - span_start.column + 1, - span_end.line, - span_end.column + 1, - ); - let _ = writeln!(result, " {}", self.error); - let _ = writeln!(result, " {}", self.line); - result.push_str(&" ".repeat(span_start.column + 4)); - result.push('^'); - if span_end.line == span_start.line { - result.push_str(&"~".repeat(span_end.column - span_start.column)); - } else { - result - .push_str(&"~".repeat(self.line.len() - span_start.column - 1)); - result.push('v'); - } - result - } -} - pub struct Extractor { include_tests: bool, output: BTreeMap, - errors: Vec, + errors: Vec, } impl Extractor { @@ -79,7 +42,7 @@ impl Extractor { &self.output } - pub fn errors(&self) -> &Vec { + pub fn errors(&self) -> &Vec { &self.errors } } @@ -103,8 +66,8 @@ impl Extractor { let file_contents = fs::read_to_string(file.path())?; let mut file_extractor = FileExtractor { extractor: self, - path: file.path().to_path_buf(), - lines: file_contents.lines().map(str::to_string).collect(), + path: file.path(), + source: &file_contents, enum_messages: HashMap::new(), }; let parsed = match syn::parse_file(&file_contents) { @@ -166,20 +129,18 @@ impl Extractor { struct FileExtractor<'a> { extractor: &'a mut Extractor, - path: PathBuf, - lines: Vec, + path: &'a Path, + source: &'a str, enum_messages: HashMap>, } impl FileExtractor<'_> { fn add_error(&mut self, error: syn::Error) { - for error in error { - self.extractor.errors.push(ParseError { - file_path: self.path.clone(), - line: self.lines[error.span().start().line - 1].clone(), - error, - }); - } + self.extractor.errors.push(syn_miette::Error::new_named( + error, + self.source, + self.path.display(), + )); } } diff --git a/apps/ariadne-extract/src/main.rs b/apps/ariadne-extract/src/main.rs index 5d4737ebc3..f09bdffc16 100644 --- a/apps/ariadne-extract/src/main.rs +++ b/apps/ariadne-extract/src/main.rs @@ -4,6 +4,7 @@ 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; @@ -38,7 +39,11 @@ fn run(args: Args) -> Result<()> { } if !extractor.errors().is_empty() { for error in extractor.errors() { - eprintln!("{}", error.pretty_print()); + let mut error_message = String::new(); + GraphicalReportHandler::new() + .render_report(&mut error_message, error) + .unwrap(); + eprintln!("{}", error.render()); } exit(extractor.errors().len().try_into().unwrap_or(i32::MAX)); } From dfb9bb025906f2674ea9ce8aa18e024c3f48b624 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 11 Sep 2025 09:50:12 -0500 Subject: [PATCH 15/43] Only process src directory --- apps/ariadne-extract/src/extractor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ariadne-extract/src/extractor.rs b/apps/ariadne-extract/src/extractor.rs index e9d6fb7779..abcbe78226 100644 --- a/apps/ariadne-extract/src/extractor.rs +++ b/apps/ariadne-extract/src/extractor.rs @@ -49,7 +49,7 @@ impl Extractor { impl Extractor { pub fn process_package(&mut self, package_path: &Path) -> Result<()> { - for file in WalkDir::new(package_path).min_depth(1) { + for file in WalkDir::new(package_path.join("src")).min_depth(1) { let result = self.process_file(file); result?; } From ee79a883ab79e70adbc86bd26b025c1f08ef2bb3 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 11 Sep 2025 10:55:02 -0500 Subject: [PATCH 16/43] Fix improper handling of Unicode in transform_format_string --- apps/ariadne-extract/src/extractor.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/ariadne-extract/src/extractor.rs b/apps/ariadne-extract/src/extractor.rs index abcbe78226..cf340d6f94 100644 --- a/apps/ariadne-extract/src/extractor.rs +++ b/apps/ariadne-extract/src/extractor.rs @@ -332,20 +332,20 @@ impl I18nEnumVariant { let mut format_start = None; let mut extra_format_layers = 0usize; let mut format_index = 0; - let mut iter = format_string.chars().enumerate(); + let mut iter = format_string.bytes().enumerate(); while let Some((index, char)) = iter.next() { - if char == '\'' { + if char == b'\'' { result.push_str(&format_string[prev_push_index..index + 1]); result.push('\''); prev_push_index = index + 1; } - if char == '{' { + 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.next(), Some((_, '{'))) { + if matches!(iter.next(), Some((_, b'{'))) { result.push_str("'{'"); prev_push_index = index + 2; } else { @@ -355,7 +355,7 @@ impl I18nEnumVariant { } continue; } - if char == '}' { + if char == b'}' { if extra_format_layers > 0 { extra_format_layers -= 1; continue; @@ -370,7 +370,7 @@ impl I18nEnumVariant { format_start = None; prev_push_index = index; continue; - } else if matches!(iter.next(), Some((_, '}'))) { + } else if matches!(iter.next(), Some((_, b'}'))) { result.push_str(&format_string[prev_push_index..index]); result.push_str("'}'"); prev_push_index = index + 2; From c460db1145c65cf884207499e7984638fd948415 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 11 Sep 2025 13:53:47 -0500 Subject: [PATCH 17/43] Add even more errors --- Cargo.lock | 70 +++++++++++- Cargo.toml | 1 - apps/ariadne-extract/Cargo.toml | 3 +- apps/ariadne-extract/src/extractor.rs | 158 ++++++++++++++++++++------ apps/ariadne-extract/src/main.rs | 15 ++- 5 files changed, 200 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe8d094e2a..641ed74c86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -493,7 +493,6 @@ version = "0.1.0" dependencies = [ "clap", "miette", - "proc-macro2", "serde", "serde_json", "syn 2.0.106", @@ -1015,6 +1014,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" @@ -4605,6 +4613,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" @@ -4880,6 +4894,7 @@ dependencies = [ "supports-color", "supports-hyperlinks", "supports-unicode", + "syntect", "terminal_size", "textwrap", "unicode-width 0.1.14", @@ -5593,6 +5608,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" @@ -8518,6 +8555,28 @@ dependencies = [ "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]] name = "sys-locale" version = "0.3.2" @@ -11160,6 +11219,15 @@ version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "yaserde" version = "0.12.0" diff --git a/Cargo.toml b/Cargo.toml index 3f51db99ab..01a4ccbb68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,7 +100,6 @@ 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" diff --git a/apps/ariadne-extract/Cargo.toml b/apps/ariadne-extract/Cargo.toml index ab43a67253..11f0fd694a 100644 --- a/apps/ariadne-extract/Cargo.toml +++ b/apps/ariadne-extract/Cargo.toml @@ -11,9 +11,8 @@ serde_json.workspace = true serde.workspace = true syn = { workspace = true, features = ["visit"] } -proc-macro2 = { workspace = true, features = ["span-locations"] } syn-miette.workspace = true -miette.workspace = true +miette = { workspace = true, features = ["syntect-highlighter"] } walkdir.workspace = true [lints] diff --git a/apps/ariadne-extract/src/extractor.rs b/apps/ariadne-extract/src/extractor.rs index cf340d6f94..86bac53519 100644 --- a/apps/ariadne-extract/src/extractor.rs +++ b/apps/ariadne-extract/src/extractor.rs @@ -1,10 +1,12 @@ use crate::error::Result; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fmt::Display; 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, @@ -66,8 +68,10 @@ impl Extractor { let file_contents = fs::read_to_string(file.path())?; let mut file_extractor = FileExtractor { extractor: self, - path: file.path(), - source: &file_contents, + file: FileInfo { + path: file.path(), + source: &file_contents, + }, enum_messages: HashMap::new(), }; let parsed = match syn::parse_file(&file_contents) { @@ -125,22 +129,30 @@ impl Extractor { .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, - enum_messages: HashMap>, } impl FileExtractor<'_> { fn add_error(&mut self, error: syn::Error) { - self.extractor.errors.push(syn_miette::Error::new_named( - error, - self.source, - self.path.display(), - )); + self.extractor.add_error(&self.file, error); } } @@ -154,14 +166,34 @@ impl Visit<'_> for FileExtractor<'_> { fn visit_item_enum(&mut self, i: &ItemEnum) { let mut variants = HashMap::new(); for variant in &i.variants { - let error_message = variant.attrs.iter().find_map(|x| { - if !x.path().is_ident("error") { - return None; + let Some(error_attr) = + variant.attrs.iter().find(|x| x.path().is_ident("error")) + else { + continue; + }; + let error_message = error_attr + .parse_args_with(|input: ParseStream| { + if 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(), message); } - x.parse_args().ok() - }); - if let Some(error_message) = error_message { - variants.insert(variant.ident.clone(), error_message); + Some(Err(err)) => { + let mut true_error = syn::Error::new( + error_attr.meta.span(), + "only #[error(transparent)] and #[error(\"lone format string\")] syntaxes are supported", + ); + true_error.combine(err); + self.add_error(true_error); + } + None => {} } } if !variants.is_empty() { @@ -193,21 +225,33 @@ impl Visit<'_> for FileExtractor<'_> { root_key, variants, } => { - let messages = self.enum_messages.get(&for_enum); + 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 message_literal = messages.get(&variant.variant_name); + let (message, errors) = message_literal + .map(LitStr::value) + .map(|x| variant.transform_format_string(x)) + .map(|(x, errors)| (TranslationEntry::new(x), errors)) + .unwrap_or_default(); self.extractor.output.insert( format!("{}.{}", root_key, variant.key.value()), - messages - .and_then(|x| x.get(&variant.variant_name)) - .map(LitStr::value) - .map(|x| variant.transform_format_string(x)) - .map(TranslationEntry::new) - .unwrap_or_default(), + message, ); + for error in errors { + self.extractor.add_error( + &self.file, + syn::Error::new( + message_literal.unwrap().span(), + error, + ), + ); + } } } } @@ -315,15 +359,19 @@ impl Parse for I18nEnumVariant { } impl I18nEnumVariant { - fn transform_format_string(&self, format_string: String) -> String { - if !format_string.contains('{') || self.fields.is_empty() { - return format_string; + fn transform_format_string( + &self, + format_string: String, + ) -> (String, Vec) { + let mut errors = vec![]; + if !format_string.contains(['\'', '{', '}']) { + return (format_string, errors); } - let name_transforms = if matches!(self.fields_type, FieldsType::Tuple) { + let known_names = if matches!(self.fields_type, FieldsType::Named) { self.fields.iter().map(Ident::to_string).collect() } else { - vec![] + HashSet::new() }; let mut result = String::new(); @@ -331,8 +379,7 @@ impl I18nEnumVariant { let mut prev_push_index = 0; let mut format_start = None; let mut extra_format_layers = 0usize; - let mut format_index = 0; - let mut iter = format_string.bytes().enumerate(); + 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]); @@ -345,7 +392,8 @@ impl I18nEnumVariant { continue; } result.push_str(&format_string[prev_push_index..index]); - if matches!(iter.next(), Some((_, b'{'))) { + if matches!(iter.peek(), Some((_, b'{'))) { + iter.next(); result.push_str("'{'"); prev_push_index = index + 2; } else { @@ -362,25 +410,61 @@ impl I18nEnumVariant { } if let Some(prev_start) = format_start { let format_variable = &format_string[prev_start..index]; - let format_variable = name_transforms - .get(format_index) - .map_or(format_variable, String::as_str); + 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 => 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_index += 1; format_start = None; prev_push_index = index; continue; - } else if matches!(iter.next(), Some((_, b'}'))) { + } 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 + (result, errors) } } diff --git a/apps/ariadne-extract/src/main.rs b/apps/ariadne-extract/src/main.rs index f09bdffc16..7bb3da20e2 100644 --- a/apps/ariadne-extract/src/main.rs +++ b/apps/ariadne-extract/src/main.rs @@ -26,13 +26,16 @@ struct Args { } fn main() { - if let Err(err) = run(Args::parse()) { - eprintln!("Failed to run extractor:\n{err}"); - exit(1); + 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<()> { +fn run(args: Args) -> Result { let mut extractor = Extractor::new(args.include_tests); for package in args.packages { extractor.process_package(&package)?; @@ -45,7 +48,6 @@ fn run(args: Args) -> Result<()> { .unwrap(); eprintln!("{}", error.render()); } - exit(extractor.errors().len().try_into().unwrap_or(i32::MAX)); } if let Some(parent) = args.out_file.parent() { @@ -53,5 +55,6 @@ fn run(args: Args) -> Result<()> { } let writer = File::create(args.out_file)?; serde_json::to_writer_pretty(writer, &extractor.output())?; - Ok(()) + + Ok(extractor.errors().len().try_into().unwrap_or(i32::MAX)) } From 5ba0bf55d4e4a0f36f05ffbed6de3da01a173a6f Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 11 Sep 2025 14:04:36 -0500 Subject: [PATCH 18/43] Prefix all labrinth error root_keys with labrinth. and fix an error --- apps/labrinth/src/auth/email/mod.rs | 2 +- apps/labrinth/src/auth/mod.rs | 2 +- apps/labrinth/src/auth/oauth/errors.rs | 2 +- apps/labrinth/src/database/models/mod.rs | 2 +- apps/labrinth/src/file_hosting/mod.rs | 2 +- apps/labrinth/src/routes/error.rs | 2 +- apps/labrinth/src/routes/v3/project_creation.rs | 4 ++-- apps/labrinth/src/search/indexing/mod.rs | 2 +- apps/labrinth/src/search/mod.rs | 2 +- apps/labrinth/src/validate/mod.rs | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/labrinth/src/auth/email/mod.rs b/apps/labrinth/src/auth/email/mod.rs index c4e0c06909..7570ca3f97 100644 --- a/apps/labrinth/src/auth/email/mod.rs +++ b/apps/labrinth/src/auth/email/mod.rs @@ -21,7 +21,7 @@ pub enum MailError { i18n_enum!( MailError, - root_key: "error.mail", + root_key: "labrinth.error.mail", Env(..) => "environment", Mail(cause) => "email", Address(cause) => "address", diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs index f16cdbae43..b8b91afea0 100644 --- a/apps/labrinth/src/auth/mod.rs +++ b/apps/labrinth/src/auth/mod.rs @@ -56,7 +56,7 @@ pub enum AuthenticationError { i18n_enum!( AuthenticationError, - root_key: "error.unauthorized", + root_key: "labrinth.error.unauthorized", Env(..) => "environment_error", Sqlx(cause) => "database_error.unknown", Database(cause) => "database_error", diff --git a/apps/labrinth/src/auth/oauth/errors.rs b/apps/labrinth/src/auth/oauth/errors.rs index be38c609f2..acb784056a 100644 --- a/apps/labrinth/src/auth/oauth/errors.rs +++ b/apps/labrinth/src/auth/oauth/errors.rs @@ -167,7 +167,7 @@ pub enum OAuthErrorType { // and 5.2 (https://datatracker.ietf.org/doc/html/rfc6749#section-5.2) i18n_enum!( OAuthErrorType, - root_key: "error.oauth", + root_key: "labrinth.error.oauth", AuthenticationError(transparent cause) => "server_error", ClientMissingRedirectURI { client_id } => "invalid_uri.none_specified", RedirectUriNotConfigured(..) => "invalid_uri.none_configured", diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 1f42f4d366..aba2f85fb0 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -62,7 +62,7 @@ pub enum DatabaseError { i18n_enum!( DatabaseError, - root_key: "error.database", + root_key: "labrinth.error.database", Database(cause) => "sqlx", RandomId! => "random_id", CacheError(cause) => "cache", diff --git a/apps/labrinth/src/file_hosting/mod.rs b/apps/labrinth/src/file_hosting/mod.rs index f0f44e0dfb..434f46326a 100644 --- a/apps/labrinth/src/file_hosting/mod.rs +++ b/apps/labrinth/src/file_hosting/mod.rs @@ -24,7 +24,7 @@ pub enum FileHostingError { i18n_enum!( FileHostingError, - root_key: "error.file_hosting_error", + root_key: "labrinth.error.file_hosting_error", S3Error(action, cause) => "s3", FileSystemError(cause) => "file_system", InvalidFilename! => "invalid_filename", diff --git a/apps/labrinth/src/routes/error.rs b/apps/labrinth/src/routes/error.rs index 10112d1045..ac8d65727d 100644 --- a/apps/labrinth/src/routes/error.rs +++ b/apps/labrinth/src/routes/error.rs @@ -108,7 +108,7 @@ pub enum ApiError { i18n_enum!( ApiError, - root_key: "error", + root_key: "labrinth.error", Env(..) => "environment_error", FileHosting(cause) => "file_hosting_error", Database(cause) => "database_error", diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index a7738818a7..3bd7ca6253 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -66,7 +66,7 @@ pub enum CreateError { FileHostingError(#[from] FileHostingError), #[error("Error while validating uploaded file: {0}")] FileValidationError(#[from] crate::validate::ValidationError), - #[error("{}", .0)] + #[error("{0}")] MissingValueError(String), // TODO: Use an I18nEnum instead of a String #[error("Invalid format for image: {0}")] InvalidIconFormat(ApiError), @@ -90,7 +90,7 @@ pub enum CreateError { i18n_enum!( CreateError, - root_key: "error.project_creation", + root_key: "labrinth.error.project_creation", EnvError(..) => "environment_error", SqlxDatabaseError(..) => "database_error.unknown", DatabaseError(cause) => "database_error", diff --git a/apps/labrinth/src/search/indexing/mod.rs b/apps/labrinth/src/search/indexing/mod.rs index 8977c1229f..93d1e09afc 100644 --- a/apps/labrinth/src/search/indexing/mod.rs +++ b/apps/labrinth/src/search/indexing/mod.rs @@ -35,7 +35,7 @@ pub enum IndexingError { i18n_enum!( IndexingError, - root_key: "error.indexing", + root_key: "labrinth.error.indexing", Indexing(..) => "meilisearch", Serde(cause) => "serialization", Sqlx(cause) => "sqlx", diff --git a/apps/labrinth/src/search/mod.rs b/apps/labrinth/src/search/mod.rs index 0f688fbe88..594e12e47e 100644 --- a/apps/labrinth/src/search/mod.rs +++ b/apps/labrinth/src/search/mod.rs @@ -33,7 +33,7 @@ pub enum SearchError { i18n_enum!( SearchError, - root_key: "error.search", + root_key: "labrinth.error.search", MeiliSearch(cause) => "meilisearch_error", Serde(cause) => "invalid_input.serialization", IntParsing(cause) => "invalid_input.int_parsing", diff --git a/apps/labrinth/src/validate/mod.rs b/apps/labrinth/src/validate/mod.rs index c5a3c1b625..1655d6b326 100644 --- a/apps/labrinth/src/validate/mod.rs +++ b/apps/labrinth/src/validate/mod.rs @@ -57,7 +57,7 @@ pub enum ValidationError { i18n_enum!( ValidationError, - root_key: "error.file_validation", + root_key: "labrinth.error.file_validation", Zip(cause) => "zip", Io(cause) => "io", SerDe(cause) => "serialization", From 4c7048e58f06f1fc5a6e67628bd7de719b542b6c Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 11 Sep 2025 14:07:18 -0500 Subject: [PATCH 19/43] Mark some error translations as transparent --- apps/labrinth/src/auth/mod.rs | 2 +- apps/labrinth/src/routes/error.rs | 2 +- apps/labrinth/src/routes/v3/project_creation.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs index b8b91afea0..810ea11093 100644 --- a/apps/labrinth/src/auth/mod.rs +++ b/apps/labrinth/src/auth/mod.rs @@ -64,7 +64,7 @@ i18n_enum!( Reqwest(..) => "network_error", FileHosting(..) => "file_hosting", Decoding(cause) => "decoding_error", - Mail(cause) => "mail_error", + Mail(transparent cause) => "mail_error", InvalidCredentials! => "invalid_credentials", InvalidAuthMethod! => "invalid_auth_method", InvalidClientId! => "invalid_client_id", diff --git a/apps/labrinth/src/routes/error.rs b/apps/labrinth/src/routes/error.rs index ac8d65727d..99cf37e0be 100644 --- a/apps/labrinth/src/routes/error.rs +++ b/apps/labrinth/src/routes/error.rs @@ -129,7 +129,7 @@ i18n_enum!( Decoding(cause) => "decoding_error", ImageParse(cause) => "invalid_image", PasswordHashing(cause) => "password_hashing_error", - Mail(cause) => "mail_error", + Mail(transparent cause) => "mail_error", Reroute(cause) => "reroute_error", Zip(cause) => "zip_error", Io(cause) => "io_error", diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 3bd7ca6253..84a77e1334 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -100,7 +100,7 @@ i18n_enum!( ValidationError(cause) => "invalid_input.validation", FileHostingError(cause) => "file_hosting_error", FileValidationError(cause) => "invalid_input.file", - MissingValueError(cause) => "invalid_input.missing_value", + MissingValueError(transparent cause) => "invalid_input.missing_value", InvalidIconFormat(cause) => "invalid_input.icon", InvalidInput(cause) => "invalid_input", InvalidLoader(loader) => "invalid_input.loader", From 248147931796594f075872b8eedc34186ca24d82 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 11 Sep 2025 15:45:11 -0500 Subject: [PATCH 20/43] Make labrinth extraction go into a frontend labrinth.json for now --- apps/frontend/src/locales/en-US/labrinth.json | 308 ++++++++++++++++++ apps/labrinth/package.json | 1 + package.json | 5 +- 3 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/locales/en-US/labrinth.json 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..9b240de39f --- /dev/null +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -0,0 +1,308 @@ +{ + "labrinth.error.clickhouse_error": { + "message": "Clickhouse Error: {cause}" + }, + "labrinth.error.conflict": { + "message": "Conflict: {cause}" + }, + "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.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": "S3 error when {action}: {cause}" + }, + "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.smtp": { + "message": "SMTP Error: {cause}" + }, + "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.project_creation.database_error": { + "message": "Database Error: {cause}" + }, + "labrinth.error.project_creation.database_error.unknown": { + "message": "An unknown database error occurred" + }, + "labrinth.error.project_creation.environment_error": { + "message": "Environment Error" + }, + "labrinth.error.project_creation.file_hosting_error": { + "message": "Error while uploading file: {cause}" + }, + "labrinth.error.project_creation.indexing_error": { + "message": "Indexing Error: {cause}" + }, + "labrinth.error.project_creation.invalid_image": { + "message": "Image Parsing Error: {cause}" + }, + "labrinth.error.project_creation.invalid_input": { + "message": "Error with multipart data: {cause}" + }, + "labrinth.error.project_creation.invalid_input.category": { + "message": "Invalid category: {category}" + }, + "labrinth.error.project_creation.invalid_input.file": { + "message": "Error while validating uploaded file: {cause}" + }, + "labrinth.error.project_creation.invalid_input.file_type": { + "message": "Invalid file type for version file: {extension}" + }, + "labrinth.error.project_creation.invalid_input.icon": { + "message": "Invalid format for image: {cause}" + }, + "labrinth.error.project_creation.invalid_input.loader": { + "message": "Invalid loader: {loader}" + }, + "labrinth.error.project_creation.invalid_input.multipart": { + "message": "Error while parsing multipart payload: {cause}" + }, + "labrinth.error.project_creation.invalid_input.parsing": { + "message": "Error while parsing JSON: {cause}" + }, + "labrinth.error.project_creation.invalid_input.slug_collision": { + "message": "Slug is already taken!" + }, + "labrinth.error.project_creation.invalid_input.validation": { + "message": "Error while validating input: {cause}" + }, + "labrinth.error.project_creation.unauthorized": { + "message": "Authentication Error: {cause}" + }, + "labrinth.error.project_creation.unauthorized.custom": { + "message": "Authentication Error: {reason}" + }, + "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.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.url_error": { + "message": "Invalid callback URL specified" + }, + "labrinth.error.xml_error": { + "message": "Internal server error: {cause}" + }, + "labrinth.error.zip_error": { + "message": "Unable to read Zip Archive: {cause}" + } +} \ No newline at end of file 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/package.json b/package.json index 6a6968493b..f0a1b3def3 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", @@ -24,7 +25,7 @@ "fix": "turbo run fix fix:ancillary --continue", "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:*", From 1e2f55bda0eef114deca665ca4383c1c3a642d05 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 11 Sep 2025 16:55:43 -0500 Subject: [PATCH 21/43] Move DatabaseError::SchemaError into its own I18nEnum --- Cargo.lock | 1 + Cargo.toml | 1 + apps/labrinth/Cargo.toml | 1 + apps/labrinth/src/auth/oauth/errors.rs | 3 +- .../database/models/legacy_loader_fields.rs | 33 +- .../src/database/models/loader_fields.rs | 288 ++++++++++-------- apps/labrinth/src/database/models/mod.rs | 32 +- apps/labrinth/src/routes/maven.rs | 2 +- apps/labrinth/src/util/webhook.rs | 2 +- apps/labrinth/src/validate/mod.rs | 2 +- 10 files changed, 211 insertions(+), 154 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 641ed74c86..dfcd3441a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4394,6 +4394,7 @@ dependencies = [ "console-subscriber", "dashmap", "deadpool-redis", + "derive_more 2.0.1", "dotenv-build", "dotenvy", "either", diff --git a/Cargo.toml b/Cargo.toml index 01a4ccbb68..baecd21ca0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ daedalus = { path = "packages/daedalus" } dashmap = "6.1.0" data-url = "0.3.1" deadpool-redis = "0.22.0" +derive_more = "2.0.1" dirs = "6.0.0" discord-rich-presence = "0.2.5" dotenv-build = "0.1.1" diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index ab19dbab3a..c2dfb05269 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -74,6 +74,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", diff --git a/apps/labrinth/src/auth/oauth/errors.rs b/apps/labrinth/src/auth/oauth/errors.rs index acb784056a..2d22b33821 100644 --- a/apps/labrinth/src/auth/oauth/errors.rs +++ b/apps/labrinth/src/auth/oauth/errors.rs @@ -8,7 +8,7 @@ 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, @@ -117,7 +117,6 @@ impl actix_web::ResponseError for OAuthError { } } -// TODO: Reference in an ApiError variant #[derive(thiserror::Error, Debug)] pub enum OAuthErrorType { #[error(transparent)] diff --git a/apps/labrinth/src/database/models/legacy_loader_fields.rs b/apps/labrinth/src/database/models/legacy_loader_fields.rs index b2f8a5b2cd..3807838824 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( + 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..99d3ce5564 100644 --- a/apps/labrinth/src/database/models/loader_fields.rs +++ b/apps/labrinth/src/database/models/loader_fields.rs @@ -1,12 +1,13 @@ use std::collections::HashMap; use std::hash::Hasher; -use super::DatabaseError; use super::ids::*; +use super::{DatabaseError, SchemaError}; use crate::database::redis::RedisPool; 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 +204,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 +215,7 @@ pub enum LoaderFieldType { ArrayEnum(LoaderFieldEnumId), ArrayBoolean, } + impl LoaderFieldType { pub fn build( field_type_name: &str, @@ -291,13 +293,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 +316,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, @@ -1038,16 +1063,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 +1078,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 +1095,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 +1108,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 +1206,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 { diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index aba2f85fb0..85e26619f6 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -55,7 +55,7 @@ pub enum DatabaseError { #[error("Error while serializing with the cache: {0}")] SerdeCacheError(#[from] serde_json::Error), #[error("Schema error: {0}")] - SchemaError(String), // TODO: Use I18nEnum instead of String + SchemaError(#[from] SchemaError), #[error("Timeout when waiting for cache subscriber")] CacheTimeout, } @@ -71,3 +71,33 @@ i18n_enum!( 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(loader_fields::VersionField), + #[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/routes/maven.rs b/apps/labrinth/src/routes/maven.rs index 8572e45a77..b56637469b 100644 --- a/apps/labrinth/src/routes/maven.rs +++ b/apps/labrinth/src/routes/maven.rs @@ -215,7 +215,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 diff --git a/apps/labrinth/src/util/webhook.rs b/apps/labrinth/src/util/webhook.rs index 46b7a48915..6c8ad3d77f 100644 --- a/apps/labrinth/src/util/webhook.rs +++ b/apps/labrinth/src/util/webhook.rs @@ -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(); diff --git a/apps/labrinth/src/validate/mod.rs b/apps/labrinth/src/validate/mod.rs index 1655d6b326..19bc95e3cc 100644 --- a/apps/labrinth/src/validate/mod.rs +++ b/apps/labrinth/src/validate/mod.rs @@ -194,7 +194,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) From 3d43c07c75fed61a28055eb83fe0d01bf5d3aac8 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 11 Sep 2025 17:11:55 -0500 Subject: [PATCH 22/43] Make CI pay attention to labrinth.json --- .github/workflows/turbo-ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 948e8af368215e834cbee54feaa882004f1eefb7 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 11 Sep 2025 18:28:52 -0500 Subject: [PATCH 23/43] Fix lint --- apps/labrinth/src/database/models/legacy_loader_fields.rs | 4 ++-- apps/labrinth/src/database/models/mod.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/labrinth/src/database/models/legacy_loader_fields.rs b/apps/labrinth/src/database/models/legacy_loader_fields.rs index 3807838824..6559d769a1 100644 --- a/apps/labrinth/src/database/models/legacy_loader_fields.rs +++ b/apps/labrinth/src/database/models/legacy_loader_fields.rs @@ -98,9 +98,9 @@ impl MinecraftGameVersion { vec![Self::from_enum_value(value)] } _ => { - return Err(SchemaError::GameVersionFieldNotEnum( + return Err(SchemaError::GameVersionFieldNotEnum(Box::new( version_field, - ) + )) .into()); } }; diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 85e26619f6..0f972dc9a9 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -79,7 +79,7 @@ pub enum SchemaError { #[error("Field name {0} is not {1}")] FieldNameMismatch(String, &'static str), #[error("Game version requires field value to be an enum: {0}")] - GameVersionFieldNotEnum(loader_fields::VersionField), + GameVersionFieldNotEnum(Box), #[error("Multiple fields for field {0}")] MultipleFields(&'static str), #[error("No version fields for field {0}")] From 85884493e38b673a6f50bd16334d9214835f0444 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Fri, 12 Sep 2025 10:41:34 -0500 Subject: [PATCH 24/43] Should fix build and lint --- apps/frontend/src/locales/en-US/labrinth.json | 24 +++++++++++++++++++ ...ff96df8525a6ed872be7a64b8f349b36b276.json} | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) rename apps/labrinth/.sqlx/{query-79c73369365ed7a09f4f48a87605d22db4a49ab5fd9943b54865448d0e9a8d67.json => query-ad48051c9ac8f233ebfeeebec834ff96df8525a6ed872be7a64b8f349b36b276.json} (68%) diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json index 9b240de39f..ed9a864377 100644 --- a/apps/frontend/src/locales/en-US/labrinth.json +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -23,6 +23,27 @@ "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}" }, @@ -248,6 +269,9 @@ "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}" }, 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" } From 98c8e45d02be2bcebe578ea26470b0e14e87be60 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Fri, 12 Sep 2025 11:58:45 -0500 Subject: [PATCH 25/43] Move FileHostingError::S3Error's first field into its own enum --- apps/ariadne-extract/src/extractor.rs | 104 ++++++++++++++---- apps/frontend/src/locales/en-US/labrinth.json | 14 ++- apps/labrinth/src/file_hosting/mod.rs | 29 ++++- apps/labrinth/src/file_hosting/s3_host.rs | 20 +++- 4 files changed, 132 insertions(+), 35 deletions(-) diff --git a/apps/ariadne-extract/src/extractor.rs b/apps/ariadne-extract/src/extractor.rs index 86bac53519..eb8cc211b6 100644 --- a/apps/ariadne-extract/src/extractor.rs +++ b/apps/ariadne-extract/src/extractor.rs @@ -142,7 +142,7 @@ impl Extractor { struct FileExtractor<'a> { extractor: &'a mut Extractor, file: FileInfo<'a>, - enum_messages: HashMap>, + enum_messages: HashMap>, } struct FileInfo<'a> { @@ -150,6 +150,18 @@ struct FileInfo<'a> { source: &'a str, } +#[derive(Clone)] +struct EnumMessage { + message: LitStr, + display_attribute_type: DisplayAttributeType, +} + +#[derive(PartialEq, Eq, Copy, Clone)] +enum DisplayAttributeType { + ThiserrorError, + DeriveMoreDisplay, +} + impl FileExtractor<'_> { fn add_error(&mut self, error: syn::Error) { self.extractor.add_error(&self.file, error); @@ -166,14 +178,23 @@ impl Visit<'_> for FileExtractor<'_> { fn visit_item_enum(&mut self, i: &ItemEnum) { let mut variants = HashMap::new(); for variant in &i.variants { - let Some(error_attr) = - variant.attrs.iter().find(|x| x.path().is_ident("error")) - else { + let Some(error_attr) = variant.attrs.iter().find(|x| { + x.path().is_ident("error") || x.path().is_ident("display") + }) else { continue; }; + let display_attribute_type = if error_attr.path().is_ident("error") + { + DisplayAttributeType::ThiserrorError + } else { + DisplayAttributeType::DeriveMoreDisplay + }; let error_message = error_attr .parse_args_with(|input: ParseStream| { - if input.peek(kw::transparent) { + if display_attribute_type + == DisplayAttributeType::ThiserrorError + && input.peek(kw::transparent) + { input.parse::()?; Ok(None) } else { @@ -183,12 +204,25 @@ impl Visit<'_> for FileExtractor<'_> { .transpose(); match error_message { Some(Ok(message)) => { - variants.insert(variant.ident.clone(), message); + variants.insert( + variant.ident.clone(), + EnumMessage { + message, + display_attribute_type, + }, + ); } Some(Err(err)) => { let mut true_error = syn::Error::new( error_attr.meta.span(), - "only #[error(transparent)] and #[error(\"lone format string\")] syntaxes are supported", + 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" + } + }, ); true_error.combine(err); self.add_error(true_error); @@ -235,8 +269,12 @@ impl Visit<'_> for FileExtractor<'_> { } let message_literal = messages.get(&variant.variant_name); let (message, errors) = message_literal - .map(LitStr::value) - .map(|x| variant.transform_format_string(x)) + .map(|x| { + variant.transform_format_string( + x.message.value(), + x.display_attribute_type, + ) + }) .map(|(x, errors)| (TranslationEntry::new(x), errors)) .unwrap_or_default(); self.extractor.output.insert( @@ -247,7 +285,7 @@ impl Visit<'_> for FileExtractor<'_> { self.extractor.add_error( &self.file, syn::Error::new( - message_literal.unwrap().span(), + message_literal.unwrap().message.span(), error, ), ); @@ -313,7 +351,8 @@ impl Parse for I18nEnumVariant { let mut transparent = None; let fields_type; - let fields = if input.peek(Token![!]) { + let fields = if input.peek(Token![!]) || input.peek(Token![=>]) { + // Immediate => also flows into this case for a better error message fields_type = FieldsType::Unit; input.parse::()?; Punctuated::new() @@ -362,6 +401,7 @@ impl I18nEnumVariant { fn transform_format_string( &self, format_string: String, + display_attribute_type: DisplayAttributeType, ) -> (String, Vec) { let mut errors = vec![]; if !format_string.contains(['\'', '{', '}']) { @@ -426,20 +466,36 @@ impl I18nEnumVariant { ); format_variable } - FieldsType::Tuple => 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::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}}}")) diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json index ed9a864377..103d35075c 100644 --- a/apps/frontend/src/locales/en-US/labrinth.json +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -69,7 +69,19 @@ "message": "Invalid Filename" }, "labrinth.error.file_hosting_error.s3": { - "message": "S3 error when {action}: {cause}" + "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" diff --git a/apps/labrinth/src/file_hosting/mod.rs b/apps/labrinth/src/file_hosting/mod.rs index 434f46326a..7b7f455d76 100644 --- a/apps/labrinth/src/file_hosting/mod.rs +++ b/apps/labrinth/src/file_hosting/mod.rs @@ -6,18 +6,16 @@ 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 { - // TODO: Use an I18nEnum instead of a String - #[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, } @@ -30,6 +28,27 @@ i18n_enum!( 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(), From 665742c6e9d75a1ee1981c94d45c9752a7d56a53 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Fri, 12 Sep 2025 15:32:07 -0500 Subject: [PATCH 26/43] Move CreateError::MissingValueError's field into its own enum --- apps/frontend/src/locales/en-US/labrinth.json | 15 ++ apps/labrinth/src/models/v3/notifications.rs | 1 + .../src/routes/v3/project_creation.rs | 46 ++++-- .../src/routes/v3/version_creation.rs | 155 +++++++++++------- 4 files changed, 145 insertions(+), 72 deletions(-) diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json index 103d35075c..f9574fa0fa 100644 --- a/apps/frontend/src/locales/en-US/labrinth.json +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -248,6 +248,21 @@ "labrinth.error.project_creation.invalid_input.validation": { "message": "Error while validating input: {cause}" }, + "labrinth.error.project_creation.missing_value.content_file_extension": { + "message": "Missing content file extension" + }, + "labrinth.error.project_creation.missing_value.content_file_name": { + "message": "Missing content file name" + }, + "labrinth.error.project_creation.missing_value.content_name": { + "message": "Missing content name" + }, + "labrinth.error.project_creation.missing_value.data_field": { + "message": "No `data` field in multipart upload" + }, + "labrinth.error.project_creation.missing_value.project_id": { + "message": "Missing project id" + }, "labrinth.error.project_creation.unauthorized": { "message": "Authentication Error: {cause}" }, diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs index 6cbd275da5..f4f7d3d780 100644 --- a/apps/labrinth/src/models/v3/notifications.rs +++ b/apps/labrinth/src/models/v3/notifications.rs @@ -66,6 +66,7 @@ pub enum NotificationBody { } impl From for Notification { + // TODO: Should be translatable fn from(notif: DBNotification) -> Self { let (name, text, link, actions) = { match ¬if.body { diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index d2b85a3985..274173284b 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -31,6 +31,7 @@ use ariadne::i18n_enum; use ariadne::ids::UserId; use ariadne::ids::base62_impl::to_base62; use chrono::Utc; +use derive_more::Display; use futures::stream::StreamExt; use image::ImageError; use itertools::Itertools; @@ -67,7 +68,7 @@ pub enum CreateError { #[error("Error while validating uploaded file: {0}")] FileValidationError(#[from] crate::validate::ValidationError), #[error("{0}")] - MissingValueError(String), // TODO: Use an I18nEnum instead of a String + MissingValueError(MissingValuePart), #[error("Invalid format for image: {0}")] InvalidIconFormat(ApiError), #[error("Error with multipart data: {0}")] @@ -112,6 +113,30 @@ i18n_enum!( 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.project_creation.missing_value", + DataField! => "data_field", + ContentName! => "content_name", + ContentFileName! => "content_file_name", + ContentFileExtension! => "content_file_extension", + ProjectId! => "project_id", +); + impl actix_web::ResponseError for CreateError { fn status_code(&self) -> StatusCode { match self { @@ -347,28 +372,23 @@ 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" { @@ -468,7 +488,7 @@ async fn project_create_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) })?; let (file_name, file_extension) = @@ -914,7 +934,7 @@ 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 { @@ -1006,7 +1026,7 @@ async fn process_icon_upload( "Icons must be smaller than 256KiB", ) .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(), diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index dd49340e26..2ac1fac806 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::{CreateError, MissingValuePart, UploadedFile}; use crate::auth::get_user_from_headers; use crate::database::models::loader_fields::{ LoaderField, LoaderFieldEnumValue, VersionField, @@ -109,7 +109,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 +183,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,17 +195,21 @@ 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() { @@ -213,12 +218,17 @@ async fn version_create_inner( )); } - 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(), @@ -227,31 +237,34 @@ async fn version_create_inner( // 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, @@ -262,14 +275,19 @@ async fn version_create_inner( if !permissions.contains(ProjectPermissions::UPLOAD_VERSION) { return Err(CreateError::CustomAuthenticationError( - "You don't have permission to upload this version!".to_string(), + "You don't have permission to upload this version!" + .to_string(), )); } - 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 +296,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 +328,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 +342,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 +363,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(String::from( + "`data` field must come before file fields", + )) })?; let loaders = selected_loaders.as_ref().ok_or_else(|| { - CreateError::InvalidInput(String::from("`data` field must come before file fields")) + CreateError::InvalidInput(String::from( + "`data` field must come before file fields", + )) })?; 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( + "`data` field is required".to_string(), + ) + })?; - 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, @@ -403,7 +442,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)) @@ -538,7 +577,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(); @@ -695,9 +734,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" { @@ -1032,13 +1069,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)) From 33fcdeda091c2a2e7ccde2aea7162f274b11c0a1 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Mon, 15 Sep 2025 09:25:22 -0500 Subject: [PATCH 27/43] Move CreateError to create_error.rs --- .../src/routes/v2/project_creation.rs | 6 +- .../src/routes/v2/version_creation.rs | 2 +- apps/labrinth/src/routes/v2_reroute.rs | 2 +- apps/labrinth/src/routes/v3/collections.rs | 2 +- apps/labrinth/src/routes/v3/create_error.rs | 138 ++++++++++++++++++ apps/labrinth/src/routes/v3/mod.rs | 1 + apps/labrinth/src/routes/v3/oauth_clients.rs | 2 +- apps/labrinth/src/routes/v3/organizations.rs | 2 +- .../src/routes/v3/project_creation.rs | 137 +---------------- .../src/routes/v3/version_creation.rs | 3 +- apps/labrinth/src/util/routes.rs | 2 +- 11 files changed, 152 insertions(+), 145 deletions(-) create mode 100644 apps/labrinth/src/routes/v3/create_error.rs 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/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_reroute.rs b/apps/labrinth/src/routes/v2_reroute.rs index 09ed91557e..aa11d819af 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::v3::project_creation::CreateError; use crate::models::v2::projects::LegacySideType; use crate::routes::error::ApiError; +use crate::routes::v3::create_error::CreateError; use crate::util::actix::{ MultipartSegment, MultipartSegmentData, generate_multipart, }; diff --git a/apps/labrinth/src/routes/v3/collections.rs b/apps/labrinth/src/routes/v3/collections.rs index f8dfbb184c..e349efa6e5 100644 --- a/apps/labrinth/src/routes/v3/collections.rs +++ b/apps/labrinth/src/routes/v3/collections.rs @@ -10,7 +10,7 @@ use crate::models::ids::{CollectionId, ProjectId}; use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; use crate::routes::error::ApiError; -use crate::routes::v3::project_creation::CreateError; +use crate::routes::v3::create_error::CreateError; use crate::util::img::delete_old_images; use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; 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..5ae7f48097 --- /dev/null +++ b/apps/labrinth/src/routes/v3/create_error.rs @@ -0,0 +1,138 @@ +use crate::auth::AuthenticationError; +use crate::database::models; +use crate::file_hosting::FileHostingError; +use crate::models::error::AsApiError; +use crate::routes::error::ApiError; +use crate::search::indexing::IndexingError; +use actix_web::HttpResponse; +use actix_web::http::StatusCode; +use ariadne::i18n_enum; +use derive_more::Display; +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(String), // TODO: Use an I18nEnum instead of a 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), // TODO: Use an I18nEnum instead of a String + #[error("Image Parsing Error: {0}")] + ImageError(#[from] ImageError), +} + +i18n_enum!( + CreateError, + root_key: "labrinth.error.project_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", + InvalidLoader(loader) => "invalid_input.loader", + InvalidCategory(category) => "invalid_input.category", + InvalidFileType(extension) => "invalid_input.file_type", + SlugCollision! => "invalid_input.slug_collision", + Unauthorized(cause) => "unauthorized", + CustomAuthenticationError(reason) => "unauthorized.custom", + 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.project_creation.missing_value", + DataField! => "data_field", + ContentName! => "content_name", + ContentFileName! => "content_file_name", + ContentFileExtension! => "content_file_extension", + ProjectId! => "project_id", +); + +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::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(self.as_api_error()) + } +} diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 4659c6deae..05ae7085fe 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -5,6 +5,7 @@ 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/oauth_clients.rs b/apps/labrinth/src/routes/v3/oauth_clients.rs index cbe6abdb2b..17aacf803c 100644 --- a/apps/labrinth/src/routes/v3/oauth_clients.rs +++ b/apps/labrinth/src/routes/v3/oauth_clients.rs @@ -3,6 +3,7 @@ use std::{collections::HashSet, fmt::Display, sync::Arc}; use crate::file_hosting::FileHostPublicity; use crate::models::ids::OAuthClientId; use crate::routes::error::ApiError; +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::{ diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 5c5fd910a7..d21777df3b 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -13,7 +13,7 @@ use crate::models::pats::Scopes; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::queue::session::AuthQueue; use crate::routes::error::ApiError; -use crate::routes::v3::project_creation::CreateError; +use crate::routes::v3::create_error::CreateError; use crate::util::img::delete_old_images; use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 274173284b..bfd141b4f3 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -1,6 +1,5 @@ -use super::ApiError; 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, }; @@ -8,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::AsApiError; use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; @@ -19,160 +17,29 @@ 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, 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::i18n_enum; use ariadne::ids::UserId; use ariadne::ids::base62_impl::to_base62; use chrono::Utc; -use derive_more::Display; 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 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(MissingValuePart), - #[error("Invalid format for image: {0}")] - InvalidIconFormat(ApiError), - #[error("Error with multipart data: {0}")] - InvalidInput(String), // TODO: Use an I18nEnum instead of a 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), // TODO: Use an I18nEnum instead of a String - #[error("Image Parsing Error: {0}")] - ImageError(#[from] ImageError), -} - -i18n_enum!( - CreateError, - root_key: "labrinth.error.project_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", - InvalidLoader(loader) => "invalid_input.loader", - InvalidCategory(category) => "invalid_input.category", - InvalidFileType(extension) => "invalid_input.file_type", - SlugCollision! => "invalid_input.slug_collision", - Unauthorized(cause) => "unauthorized", - CustomAuthenticationError(reason) => "unauthorized.custom", - 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.project_creation.missing_value", - DataField! => "data_field", - ContentName! => "content_name", - ContentFileName! => "content_file_name", - ContentFileExtension! => "content_file_extension", - ProjectId! => "project_id", -); - -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::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(self.as_api_error()) - } -} - pub fn default_project_type() -> String { "mod".to_string() } diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 2ac1fac806..21410bd8aa 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, MissingValuePart, 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,7 @@ 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, MissingValuePart}; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; use crate::validate::{ValidationResult, validate_file}; diff --git a/apps/labrinth/src/util/routes.rs b/apps/labrinth/src/util/routes.rs index 05309c0687..3c6ddbb8dc 100644 --- a/apps/labrinth/src/util/routes.rs +++ b/apps/labrinth/src/util/routes.rs @@ -1,5 +1,5 @@ use crate::routes::error::ApiError; -use crate::routes::v3::project_creation::CreateError; +use crate::routes::v3::create_error::CreateError; use crate::util::validate::validation_errors_to_string; use actix_multipart::Field; use actix_web::web::Payload; From 77d936cec9168b8782baeb6485c83b89546fc5f6 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Mon, 15 Sep 2025 09:37:16 -0500 Subject: [PATCH 28/43] Rename labrinth.error.project_creation to labrinth.error.creation --- apps/frontend/src/locales/en-US/labrinth.json | 138 +++++++++--------- apps/labrinth/src/routes/v3/create_error.rs | 4 +- 2 files changed, 71 insertions(+), 71 deletions(-) diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json index f9574fa0fa..65bb8f8fbf 100644 --- a/apps/frontend/src/locales/en-US/labrinth.json +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -5,6 +5,75 @@ "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.category": { + "message": "Invalid category: {category}" + }, + "labrinth.error.creation.invalid_input.file": { + "message": "Error while validating uploaded file: {cause}" + }, + "labrinth.error.creation.invalid_input.file_type": { + "message": "Invalid file type for version file: {extension}" + }, + "labrinth.error.creation.invalid_input.icon": { + "message": "Invalid format for image: {cause}" + }, + "labrinth.error.creation.invalid_input.loader": { + "message": "Invalid loader: {loader}" + }, + "labrinth.error.creation.invalid_input.multipart": { + "message": "Error while parsing multipart payload: {cause}" + }, + "labrinth.error.creation.invalid_input.parsing": { + "message": "Error while parsing JSON: {cause}" + }, + "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}" + }, + "labrinth.error.creation.unauthorized.custom": { + "message": "Authentication Error: {reason}" + }, "labrinth.error.database.cache": { "message": "Error while interacting with the cache: {cause}" }, @@ -200,75 +269,6 @@ "labrinth.error.payments_error": { "message": "Payments Error: {cause}" }, - "labrinth.error.project_creation.database_error": { - "message": "Database Error: {cause}" - }, - "labrinth.error.project_creation.database_error.unknown": { - "message": "An unknown database error occurred" - }, - "labrinth.error.project_creation.environment_error": { - "message": "Environment Error" - }, - "labrinth.error.project_creation.file_hosting_error": { - "message": "Error while uploading file: {cause}" - }, - "labrinth.error.project_creation.indexing_error": { - "message": "Indexing Error: {cause}" - }, - "labrinth.error.project_creation.invalid_image": { - "message": "Image Parsing Error: {cause}" - }, - "labrinth.error.project_creation.invalid_input": { - "message": "Error with multipart data: {cause}" - }, - "labrinth.error.project_creation.invalid_input.category": { - "message": "Invalid category: {category}" - }, - "labrinth.error.project_creation.invalid_input.file": { - "message": "Error while validating uploaded file: {cause}" - }, - "labrinth.error.project_creation.invalid_input.file_type": { - "message": "Invalid file type for version file: {extension}" - }, - "labrinth.error.project_creation.invalid_input.icon": { - "message": "Invalid format for image: {cause}" - }, - "labrinth.error.project_creation.invalid_input.loader": { - "message": "Invalid loader: {loader}" - }, - "labrinth.error.project_creation.invalid_input.multipart": { - "message": "Error while parsing multipart payload: {cause}" - }, - "labrinth.error.project_creation.invalid_input.parsing": { - "message": "Error while parsing JSON: {cause}" - }, - "labrinth.error.project_creation.invalid_input.slug_collision": { - "message": "Slug is already taken!" - }, - "labrinth.error.project_creation.invalid_input.validation": { - "message": "Error while validating input: {cause}" - }, - "labrinth.error.project_creation.missing_value.content_file_extension": { - "message": "Missing content file extension" - }, - "labrinth.error.project_creation.missing_value.content_file_name": { - "message": "Missing content file name" - }, - "labrinth.error.project_creation.missing_value.content_name": { - "message": "Missing content name" - }, - "labrinth.error.project_creation.missing_value.data_field": { - "message": "No `data` field in multipart upload" - }, - "labrinth.error.project_creation.missing_value.project_id": { - "message": "Missing project id" - }, - "labrinth.error.project_creation.unauthorized": { - "message": "Authentication Error: {cause}" - }, - "labrinth.error.project_creation.unauthorized.custom": { - "message": "Authentication Error: {reason}" - }, "labrinth.error.ratelimit_error": { "message": "You are being rate-limited. Please wait {wait_ms} milliseconds. 0/{total_allowed_requests} remaining." }, diff --git a/apps/labrinth/src/routes/v3/create_error.rs b/apps/labrinth/src/routes/v3/create_error.rs index 5ae7f48097..56f6afbbd1 100644 --- a/apps/labrinth/src/routes/v3/create_error.rs +++ b/apps/labrinth/src/routes/v3/create_error.rs @@ -55,7 +55,7 @@ pub enum CreateError { i18n_enum!( CreateError, - root_key: "labrinth.error.project_creation", + root_key: "labrinth.error.creation", EnvError(..) => "environment_error", SqlxDatabaseError(..) => "database_error.unknown", DatabaseError(cause) => "database_error", @@ -93,7 +93,7 @@ pub enum MissingValuePart { i18n_enum!( MissingValuePart, - root_key: "labrinth.error.project_creation.missing_value", + root_key: "labrinth.error.creation.missing_value", DataField! => "data_field", ContentName! => "content_name", ContentFileName! => "content_file_name", From df221504b7ac68923ff83dbde0b785dd09576e33 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Mon, 15 Sep 2025 12:38:07 -0500 Subject: [PATCH 29/43] Mostly migrate CreateError::InvalidInput to I18nEnum --- Cargo.lock | 1 + Cargo.toml | 1 + apps/ariadne-extract/Cargo.toml | 4 +- apps/ariadne-extract/src/extractor.rs | 108 +++++-- apps/frontend/src/locales/en-US/labrinth.json | 84 ++++++ apps/labrinth/src/routes/v2_reroute.rs | 22 +- apps/labrinth/src/routes/v3/create_error.rs | 102 ++++++- apps/labrinth/src/routes/v3/organizations.rs | 275 ++++++++---------- .../src/routes/v3/project_creation.rs | 118 ++++---- .../src/routes/v3/version_creation.rs | 92 +++--- apps/labrinth/src/util/routes.rs | 6 +- 11 files changed, 519 insertions(+), 294 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f7cd07f1fa..c68e2b4769 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -493,6 +493,7 @@ version = "0.1.0" dependencies = [ "clap", "miette", + "proc-macro2", "serde", "serde_json", "syn 2.0.106", diff --git a/Cargo.toml b/Cargo.toml index a39b5e56b2..32a94ead26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,6 +101,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" diff --git a/apps/ariadne-extract/Cargo.toml b/apps/ariadne-extract/Cargo.toml index 11f0fd694a..4682937bbc 100644 --- a/apps/ariadne-extract/Cargo.toml +++ b/apps/ariadne-extract/Cargo.toml @@ -11,9 +11,11 @@ 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"] } -walkdir.workspace = true [lints] workspace = true diff --git a/apps/ariadne-extract/src/extractor.rs b/apps/ariadne-extract/src/extractor.rs index eb8cc211b6..be16482f03 100644 --- a/apps/ariadne-extract/src/extractor.rs +++ b/apps/ariadne-extract/src/extractor.rs @@ -1,9 +1,10 @@ use crate::error::Result; -use serde::{Deserialize, Serialize}; +use serde::{Serialize}; use std::collections::{BTreeMap, HashMap, HashSet}; -use std::fmt::Display; +use std::collections::btree_map::Entry; use std::fs; use std::path::Path; +use proc_macro2::Span; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; use syn::spanned::Spanned; @@ -14,14 +15,16 @@ use syn::{ }; use walkdir::{DirEntry, WalkDir}; -#[derive(Default, Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize)] pub struct TranslationEntry { pub message: String, + #[serde(skip)] + key_span: Span, } impl TranslationEntry { - pub fn new(message: String) -> Self { - Self { message } + pub fn new(message: String, key_span: Span) -> Self { + Self { message, key_span, } } } @@ -151,9 +154,23 @@ struct FileInfo<'a> { } #[derive(Clone)] -struct EnumMessage { - message: LitStr, - display_attribute_type: DisplayAttributeType, +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)] @@ -177,10 +194,14 @@ impl Visit<'_> for FileExtractor<'_> { 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(); + 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") @@ -206,14 +227,14 @@ impl Visit<'_> for FileExtractor<'_> { Some(Ok(message)) => { variants.insert( variant.ident.clone(), - EnumMessage { + EnumMessage::Present { message, display_attribute_type, }, ); } - Some(Err(err)) => { - let mut true_error = syn::Error::new( + Some(Err(_)) => { + self.add_error(syn::Error::new( error_attr.meta.span(), match display_attribute_type { DisplayAttributeType::ThiserrorError => { @@ -223,14 +244,26 @@ impl Visit<'_> for FileExtractor<'_> { "only #[display(\"lone format string\")] syntax is supported" } }, - ); - true_error.combine(err); - self.add_error(true_error); + )); + ignored_variants.insert(variant.ident.clone()); + } + None => { + ignored_variants.insert(variant.ident.clone()); } - None => {} } } 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(|| EnumMessage::Absent { + variant_span: variant.span(), + }); + } + } self.enum_messages.insert(i.ident.clone(), variants); } } @@ -267,25 +300,46 @@ impl Visit<'_> for FileExtractor<'_> { if variant.transparent.is_some() { continue; } - let message_literal = messages.get(&variant.variant_name); + let Some(message_literal) = messages.get(&variant.variant_name) else { + continue; + }; let (message, errors) = message_literal - .map(|x| { + .as_option() + .map(|(message, display_attribute_type)| { variant.transform_format_string( - x.message.value(), - x.display_attribute_type, + message.value(), + display_attribute_type, ) }) - .map(|(x, errors)| (TranslationEntry::new(x), errors)) - .unwrap_or_default(); - self.extractor.output.insert( - format!("{}.{}", root_key, variant.key.value()), - message, - ); + .unwrap_or_else(|| ("".into(), vec![format!("no default message specified for variant {}", variant.variant_name)])); + 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 {}", 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( - message_literal.unwrap().message.span(), + match message_literal { + EnumMessage::Absent { variant_span } => *variant_span, + EnumMessage::Present { message, .. } => message.span(), + }, error, ), ); @@ -402,7 +456,7 @@ impl I18nEnumVariant { &self, format_string: String, display_attribute_type: DisplayAttributeType, - ) -> (String, Vec) { + ) -> (String, Vec) { let mut errors = vec![]; if !format_string.contains(['\'', '{', '}']) { return (format_string, errors); diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json index 65bb8f8fbf..e6d22c0257 100644 --- a/apps/frontend/src/locales/en-US/labrinth.json +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -26,27 +26,111 @@ "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!" }, diff --git a/apps/labrinth/src/routes/v2_reroute.rs b/apps/labrinth/src/routes/v2_reroute.rs index aa11d819af..f6a45f579f 100644 --- a/apps/labrinth/src/routes/v2_reroute.rs +++ b/apps/labrinth/src/routes/v2_reroute.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use crate::models::v2::projects::LegacySideType; use crate::routes::error::ApiError; -use crate::routes::v3::create_error::CreateError; +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/create_error.rs b/apps/labrinth/src/routes/v3/create_error.rs index 56f6afbbd1..5d06381dfc 100644 --- a/apps/labrinth/src/routes/v3/create_error.rs +++ b/apps/labrinth/src/routes/v3/create_error.rs @@ -2,12 +2,14 @@ use crate::auth::AuthenticationError; use crate::database::models; 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 actix_web::HttpResponse; use actix_web::http::StatusCode; use ariadne::i18n_enum; -use derive_more::Display; +use derive_more::{Display, From}; use image::ImageError; use thiserror::Error; @@ -36,7 +38,7 @@ pub enum CreateError { #[error("Invalid format for image: {0}")] InvalidIconFormat(ApiError), #[error("Error with multipart data: {0}")] - InvalidInput(String), // TODO: Use an I18nEnum instead of a String + InvalidInput(CreationInvalidInput), #[error("Invalid loader: {0}")] InvalidLoader(String), #[error("Invalid category: {0}")] @@ -101,6 +103,102 @@ i18n_enum!( 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, +} + +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", +); + impl actix_web::ResponseError for CreateError { fn status_code(&self) -> StatusCode { match self { diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index d21777df3b..6f1cc82df7 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -13,7 +13,7 @@ use crate::models::pats::Scopes; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::queue::session::AuthQueue; use crate::routes::error::ApiError; -use crate::routes::v3::create_error::CreateError; +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, @@ -524,7 +523,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, @@ -561,30 +560,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, @@ -632,10 +629,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 +647,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 +661,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 +699,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,32 +725,30 @@ 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 { @@ -822,11 +812,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, @@ -869,17 +855,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 +883,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 +903,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 +919,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 +933,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 +989,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, @@ -1058,17 +1033,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, @@ -1134,7 +1108,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 +1137,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, @@ -1218,7 +1191,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/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index bfd141b4f3..d0dab9ff30 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -17,7 +17,9 @@ use crate::models::projects::{ use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::models::threads::ThreadType; use crate::queue::session::AuthQueue; -use crate::routes::v3::create_error::{CreateError, MissingValuePart}; +use crate::routes::v3::create_error::{ + CreateError, CreationInvalidInput, MissingValuePart, +}; use crate::util::img::upload_image_optimized; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; @@ -259,9 +261,9 @@ async fn project_create_inner( })?; 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(); @@ -320,9 +322,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( @@ -352,7 +354,8 @@ 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(MissingValuePart::ContentName) @@ -363,9 +366,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( @@ -382,20 +385,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( @@ -430,9 +437,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]; @@ -488,9 +501,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, + )); } } @@ -539,7 +552,7 @@ async fn project_create_inner( .await? .ok_or_else(|| { CreateError::InvalidInput( - "Invalid organization ID specified!".to_string(), + CreationInvalidInput::InvalidOrganizationId, ) })?; @@ -585,20 +598,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![]; @@ -612,19 +620,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, @@ -689,9 +699,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!( @@ -709,9 +721,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), + )); } } @@ -806,9 +818,9 @@ async fn create_initial_version( 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| { @@ -890,7 +902,7 @@ 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 = upload_image_optimized( diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 21410bd8aa..16cb4fb421 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -23,7 +23,9 @@ 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, MissingValuePart}; +use crate::routes::v3::create_error::{ + CreateError, CreationInvalidInput, MissingValuePart, +}; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; use crate::validate::{ValidationResult, validate_file}; @@ -215,7 +217,9 @@ async fn version_create_inner( 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, + ), )); } @@ -232,7 +236,7 @@ async fn version_create_inner( .is_none() { return Err(CreateError::InvalidInput( - "An invalid project id was supplied".to_string(), + CreationInvalidInput::InvalidProjectId, )); } @@ -364,14 +368,14 @@ 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() @@ -381,7 +385,7 @@ async fn version_create_inner( let version_data = initial_version_data.clone().ok_or_else(|| { CreateError::InvalidInput( - "`data` field is required".to_string(), + CreationInvalidInput::MissingDataField, ) })?; @@ -424,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, )); } @@ -528,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!( @@ -547,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), + )); } } @@ -648,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, )); }; @@ -675,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, )); } @@ -750,9 +756,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 @@ -808,7 +814,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 { @@ -849,14 +855,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, )); } @@ -866,9 +871,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!( @@ -889,8 +896,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, )); } @@ -1006,8 +1012,7 @@ 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, )); } @@ -1104,9 +1109,11 @@ 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 @@ -1124,10 +1131,11 @@ pub fn try_create_version_fields( } 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/util/routes.rs b/apps/labrinth/src/util/routes.rs index 3c6ddbb8dc..00011bb8e6 100644 --- a/apps/labrinth/src/util/routes.rs +++ b/apps/labrinth/src/util/routes.rs @@ -1,5 +1,5 @@ use crate::routes::error::ApiError; -use crate::routes::v3::create_error::CreateError; +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); From 4217f4bdf6cfbb2b5514ad60e41092d65ccdf375 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Mon, 15 Sep 2025 13:33:21 -0500 Subject: [PATCH 30/43] Make VersionField*::parse return a Result<_, VersionFieldParseError> --- apps/frontend/src/locales/en-US/labrinth.json | 12 ++++ .../src/database/models/loader_fields.rs | 65 +++++++++++++------ apps/labrinth/src/routes/v3/create_error.rs | 5 ++ .../src/routes/v3/version_creation.rs | 5 +- 4 files changed, 65 insertions(+), 22 deletions(-) diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json index e6d22c0257..568bfd7162 100644 --- a/apps/frontend/src/locales/en-US/labrinth.json +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -434,6 +434,18 @@ "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}" }, diff --git a/apps/labrinth/src/database/models/loader_fields.rs b/apps/labrinth/src/database/models/loader_fields.rs index 99d3ce5564..33f151e6b0 100644 --- a/apps/labrinth/src/database/models/loader_fields.rs +++ b/apps/labrinth/src/database/models/loader_fields.rs @@ -4,6 +4,7 @@ use std::hash::Hasher; 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; @@ -809,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)?; @@ -829,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, )); } } @@ -966,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, ) }; @@ -1020,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(), )); } }), @@ -1038,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 @@ -1381,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/routes/v3/create_error.rs b/apps/labrinth/src/routes/v3/create_error.rs index 5d06381dfc..c8bc246cc7 100644 --- a/apps/labrinth/src/routes/v3/create_error.rs +++ b/apps/labrinth/src/routes/v3/create_error.rs @@ -1,5 +1,6 @@ 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; @@ -39,6 +40,8 @@ pub enum CreateError { 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}")] @@ -70,6 +73,7 @@ i18n_enum!( 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", @@ -216,6 +220,7 @@ impl actix_web::ResponseError for CreateError { 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, diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 16cb4fb421..a5ee37008d 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -1120,13 +1120,12 @@ pub fn try_create_version_fields( .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); } From 0cb84b029fa299e911939f6db9714f275ac32d4a Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Mon, 15 Sep 2025 14:19:54 -0500 Subject: [PATCH 31/43] Add ApiError::InvalidLoaderField --- apps/labrinth/src/routes/error.rs | 6 ++++++ apps/labrinth/src/routes/v3/versions.rs | 5 ++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/labrinth/src/routes/error.rs b/apps/labrinth/src/routes/error.rs index 4d757ba63d..0dc446c2f5 100644 --- a/apps/labrinth/src/routes/error.rs +++ b/apps/labrinth/src/routes/error.rs @@ -1,3 +1,4 @@ +use crate::database::models::loader_fields::VersionFieldParseError; use crate::file_hosting::FileHostingError; use crate::models::error::AsApiError; use actix_web::http::StatusCode; @@ -42,6 +43,9 @@ pub enum ApiError { #[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), @@ -123,6 +127,7 @@ i18n_enum!( Authentication(cause) => "unauthorized", CustomAuthentication(cause) => "unauthorized", InvalidInput(cause) => "invalid_input", + InvalidLoaderField(cause) => "invalid_input", Validation(cause) => "invalid_input.validation", Search(cause) => "search_error", Indexing(cause) => "indexing_error", @@ -161,6 +166,7 @@ impl ResponseError for ApiError { 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, diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index e0b3460068..bbf9825bf4 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -477,13 +477,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) From f7bb0946cf8fe10da509002d40defaff9dfc6222 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Mon, 15 Sep 2025 14:32:25 -0500 Subject: [PATCH 32/43] Add CreationInvalidInput::Validation --- apps/labrinth/src/routes/v3/collections.rs | 6 ++++-- apps/labrinth/src/routes/v3/create_error.rs | 2 ++ apps/labrinth/src/routes/v3/project_creation.rs | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/labrinth/src/routes/v3/collections.rs b/apps/labrinth/src/routes/v3/collections.rs index e349efa6e5..eae8c95cae 100644 --- a/apps/labrinth/src/routes/v3/collections.rs +++ b/apps/labrinth/src/routes/v3/collections.rs @@ -10,7 +10,7 @@ use crate::models::ids::{CollectionId, ProjectId}; use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; use crate::routes::error::ApiError; -use crate::routes::v3::create_error::CreateError; +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?; diff --git a/apps/labrinth/src/routes/v3/create_error.rs b/apps/labrinth/src/routes/v3/create_error.rs index c8bc246cc7..92d6a21808 100644 --- a/apps/labrinth/src/routes/v3/create_error.rs +++ b/apps/labrinth/src/routes/v3/create_error.rs @@ -168,6 +168,7 @@ pub enum CreationInvalidInput { MissingLoaderFields(String), #[display("No json segment found in multipart.")] NoJsonInMultipart, + Validation(String), } i18n_enum!( @@ -201,6 +202,7 @@ i18n_enum!( NonexistentLoaderField(field) => "nonexistent_loader_field", MissingLoaderFields(fields) => "missing_loader_fields", NoJsonInMultipart! => "no_json_in_multipart", + Validation(transparent reason) => "validation", ); impl actix_web::ResponseError for CreateError { diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index d0dab9ff30..459be6f6d5 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -275,7 +275,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 = From 40ba23b012e2e332c6836e1eadd268e670312502 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Mon, 15 Sep 2025 15:10:58 -0500 Subject: [PATCH 33/43] Move ValidationResult::Warning to I18nEnum --- apps/frontend/src/locales/en-US/labrinth.json | 63 ++++++++++++ apps/labrinth/src/routes/v3/create_error.rs | 3 + .../src/routes/v3/version_creation.rs | 6 +- apps/labrinth/src/validate/datapack.rs | 4 +- apps/labrinth/src/validate/fabric.rs | 5 +- apps/labrinth/src/validate/forge.rs | 7 +- apps/labrinth/src/validate/liteloader.rs | 5 +- apps/labrinth/src/validate/mod.rs | 97 +++++++++++++++++-- apps/labrinth/src/validate/modpack.rs | 4 +- apps/labrinth/src/validate/neoforge.rs | 5 +- apps/labrinth/src/validate/plugin.rs | 10 +- apps/labrinth/src/validate/quilt.rs | 5 +- apps/labrinth/src/validate/resourcepack.rs | 6 +- apps/labrinth/src/validate/rift.rs | 5 +- apps/labrinth/src/validate/shader.rs | 10 +- 15 files changed, 196 insertions(+), 39 deletions(-) diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json index 568bfd7162..e27ed03df7 100644 --- a/apps/frontend/src/locales/en-US/labrinth.json +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -451,5 +451,68 @@ }, "labrinth.error.zip_error": { "message": "Unable to read Zip Archive: {cause}" + }, + "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/src/routes/v3/create_error.rs b/apps/labrinth/src/routes/v3/create_error.rs index 92d6a21808..ce00e61b99 100644 --- a/apps/labrinth/src/routes/v3/create_error.rs +++ b/apps/labrinth/src/routes/v3/create_error.rs @@ -7,6 +7,7 @@ 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; @@ -168,6 +169,7 @@ pub enum CreationInvalidInput { MissingLoaderFields(String), #[display("No json segment found in multipart.")] NoJsonInMultipart, + FileValidation(ValidationWarning), Validation(String), } @@ -202,6 +204,7 @@ i18n_enum!( NonexistentLoaderField(field) => "nonexistent_loader_field", MissingLoaderFields(fields) => "missing_loader_fields", NoJsonInMultipart! => "no_json_in_multipart", + FileValidation(transparent cause) => "file_validation", Validation(transparent reason) => "validation", ); diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index a5ee37008d..37e7f171c5 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -1016,10 +1016,12 @@ pub async fn upload_file( )); } - 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}"); 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 19bc95e3cc..c5b825191d 100644 --- a/apps/labrinth/src/validate/mod.rs +++ b/apps/labrinth/src/validate/mod.rs @@ -20,6 +20,7 @@ use crate::validate::shader::{ 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; @@ -66,6 +67,92 @@ i18n_enum!( 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 @@ -76,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), // TODO: Use an I18nEnum + Warning(ValidationWarning), } impl ValidationResult { @@ -276,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(), @@ -337,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, )) } } From 55a6ecca800706e6bd65b90f5e37d56fbb49610f Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Mon, 15 Sep 2025 15:32:35 -0500 Subject: [PATCH 34/43] Fix lint --- apps/frontend/src/locales/en-US/labrinth.json | 6 ++++++ apps/labrinth/src/models/v3/notifications.rs | 2 +- apps/labrinth/src/queue/email.rs | 4 ++-- apps/labrinth/src/queue/email/templates.rs | 2 +- .../src/routes/internal/external_notifications.rs | 2 +- apps/labrinth/src/routes/internal/flows.rs | 12 +++++++----- apps/labrinth/src/routes/v2/version_file.rs | 2 +- 7 files changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json index e27ed03df7..b08c81c3e3 100644 --- a/apps/frontend/src/locales/en-US/labrinth.json +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -299,9 +299,15 @@ "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" }, diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs index 638dbfaef0..22e19a9819 100644 --- a/apps/labrinth/src/models/v3/notifications.rs +++ b/apps/labrinth/src/models/v3/notifications.rs @@ -7,10 +7,10 @@ use crate::models::ids::{ VersionId, }; use crate::models::projects::ProjectStatus; +use crate::routes::error::ApiError; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::routes::error::ApiError; #[derive(Serialize, Deserialize)] pub struct Notification { diff --git a/apps/labrinth/src/queue/email.rs b/apps/labrinth/src/queue/email.rs index b5657f4900..daa4267d29 100644 --- a/apps/labrinth/src/queue/email.rs +++ b/apps/labrinth/src/queue/email.rs @@ -8,6 +8,8 @@ use crate::models::notifications::NotificationBody; use crate::models::v3::notifications::{ NotificationChannel, NotificationDeliveryStatus, }; +use crate::routes::error::ApiError; +use ariadne::i18n_enum; use chrono::Utc; use futures::stream::{FuturesUnordered, StreamExt}; use lettre::message::Mailbox; @@ -20,8 +22,6 @@ use std::sync::Arc; use thiserror::Error; use tokio::sync::Mutex as TokioMutex; use tracing::{error, info, instrument, warn}; -use ariadne::i18n_enum; -use crate::routes::error::ApiError; const EMAIL_RETRY_DELAY_SECONDS: i64 = 10; diff --git a/apps/labrinth/src/queue/email/templates.rs b/apps/labrinth/src/queue/email/templates.rs index e162cd522c..6ea9655d32 100644 --- a/apps/labrinth/src/queue/email/templates.rs +++ b/apps/labrinth/src/queue/email/templates.rs @@ -5,6 +5,7 @@ use crate::database::models::ids::*; use crate::database::models::notifications_template_item::NotificationTemplate; use crate::database::redis::RedisPool; use crate::models::v3::notifications::NotificationBody; +use crate::routes::error::ApiError; use futures::TryFutureExt; use lettre::Message; use lettre::message::{Mailbox, MultiPart, SinglePart}; @@ -12,7 +13,6 @@ use sqlx::query; use std::collections::HashMap; use std::time::Duration; use tracing::{error, warn}; -use crate::routes::error::ApiError; const USER_NAME: &str = "user.name"; const USER_EMAIL: &str = "user.email"; diff --git a/apps/labrinth/src/routes/internal/external_notifications.rs b/apps/labrinth/src/routes/internal/external_notifications.rs index 24604abded..488857ffcb 100644 --- a/apps/labrinth/src/routes/internal/external_notifications.rs +++ b/apps/labrinth/src/routes/internal/external_notifications.rs @@ -3,13 +3,13 @@ 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::error::ApiError; use crate::util::guards::external_notification_key_guard; use actix_web::web; use actix_web::{HttpResponse, post}; use ariadne::ids::UserId; use serde::Deserialize; use sqlx::PgPool; -use crate::routes::error::ApiError; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(create); diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index e7dbe8f1d2..97354ba242 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -1167,11 +1167,13 @@ 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?; diff --git a/apps/labrinth/src/routes/v2/version_file.rs b/apps/labrinth/src/routes/v2/version_file.rs index 3392e44e92..ffd0da7abe 100644 --- a/apps/labrinth/src/routes/v2/version_file.rs +++ b/apps/labrinth/src/routes/v2/version_file.rs @@ -1,5 +1,5 @@ -use crate::database::redis::RedisPool; 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; From 2ed1d95dd01a0755e968780c8decf245adcfcc70 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Tue, 16 Sep 2025 09:28:24 -0500 Subject: [PATCH 35/43] Migrate Notification to I18nEnum --- apps/labrinth/src/models/error.rs | 4 +- apps/labrinth/src/models/v3/notifications.rs | 301 +++++++++---------- apps/labrinth/src/models/v3/projects.rs | 1 + packages/ariadne/src/i18n.rs | 38 ++- 4 files changed, 176 insertions(+), 168 deletions(-) diff --git a/apps/labrinth/src/models/error.rs b/apps/labrinth/src/models/error.rs index b1b3e3d886..48934c60b7 100644 --- a/apps/labrinth/src/models/error.rs +++ b/apps/labrinth/src/models/error.rs @@ -1,9 +1,9 @@ use ariadne::i18n::{I18nEnum, TranslationData}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::fmt::Display; /// An error returned by the API -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct ApiError { pub error: &'static str, pub description: String, diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs index 22e19a9819..d9a582e9dd 100644 --- a/apps/labrinth/src/models/v3/notifications.rs +++ b/apps/labrinth/src/models/v3/notifications.rs @@ -8,8 +8,11 @@ use crate::models::ids::{ }; use crate::models::projects::ProjectStatus; 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,55 +24,82 @@ 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, + #[display("LEGACY MARKDOWN NOTIFICATION")] LegacyMarkdown, + #[display("Password reset requested")] ResetPassword, + // 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("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("")] 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", + 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::Unknown => "unknown", - } + self.translation_id() } pub fn from_str_or_default(s: &str) -> Self { @@ -96,30 +126,39 @@ 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, }, + #[display("Click on the link to read more.")] ModeratorMessage { thread_id: ThreadId, message_id: ThreadMessageId, @@ -127,6 +166,7 @@ pub enum NotificationBody { project_id: Option, report_id: Option, }, + #[display("{text}")] LegacyMarkdown { notification_type: Option, name: String, @@ -134,33 +174,59 @@ 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("")] Unknown, } +i18n_enum!( + NotificationBody, + root_key: "labrinth.notification.type", + 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", + 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", + Unknown! => "unknown", +); + impl NotificationBody { pub fn notification_type(&self) -> NotificationType { match &self { @@ -216,31 +282,21 @@ impl NotificationBody { } impl From for Notification { - // TODO: Should be translatable 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 { @@ -264,15 +320,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 { @@ -294,27 +344,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 { @@ -325,87 +362,45 @@ impl From for Notification { 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(), - ), - // 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::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::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::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(), @@ -415,7 +410,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..b0a8ecb9b6 100644 --- a/apps/labrinth/src/models/v3/projects.rs +++ b/apps/labrinth/src/models/v3/projects.rs @@ -427,6 +427,7 @@ impl From for Link { /// Private - Project is approved, but is not viewable to the public #[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)] #[serde(rename_all = "lowercase")] +// TODO: Make translatable and remove as_friendly_str pub enum ProjectStatus { Approved, Archived, diff --git a/packages/ariadne/src/i18n.rs b/packages/ariadne/src/i18n.rs index 0b02bf67cb..64c3f19501 100644 --- a/packages/ariadne/src/i18n.rs +++ b/packages/ariadne/src/i18n.rs @@ -1,5 +1,6 @@ -use serde::Serialize; -use std::collections::HashMap; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::collections::BTreeMap; pub trait I18nEnum { const ROOT_TRANSLATION_ID: &'static str; @@ -11,14 +12,14 @@ pub trait I18nEnum { fn translation_data(&self) -> TranslationData; } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum TranslationData { Literal(String), Translatable { - key: &'static str, - #[serde(skip_serializing_if = "HashMap::is_empty")] - values: HashMap<&'static str, TranslationData>, + key: Cow<'static, str>, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + values: BTreeMap, TranslationData>, }, } @@ -103,6 +104,9 @@ macro_rules! __i18n_enum_variant_parameters_no_store { ($variant_name:ident, (transparent $_:ident)) => { $variant_name(_) }; + ($variant_name:ident, { transparent $_:ident }) => { + $variant_name { .. } + }; ($variant_name:ident, ($($_:tt)+)) => { $variant_name(..) }; @@ -120,6 +124,9 @@ macro_rules! __i18n_enum_variant_parameters { ($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)+) }; @@ -133,8 +140,8 @@ macro_rules! __i18n_enum_variant_parameters { macro_rules! __i18n_enum_variant_values { ($root_key:literal, $key:literal, !) => { $crate::i18n::TranslationData::Translatable { - key: ::core::concat!($root_key, ".", $key), - values: ::std::collections::HashMap::new(), + key: ::std::borrow::Cow::Borrowed(::core::concat!($root_key, ".", $key)), + values: ::std::collections::BTreeMap::new(), } }; ($root_key:literal, $key:literal, (..)) => { @@ -146,15 +153,18 @@ macro_rules! __i18n_enum_variant_values { ($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: ::core::concat!($root_key, ".", $key), - values: ::std::collections::HashMap::from([ - $((::core::stringify!($field), $field.__maybe_translate()),)* + 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),*}) => { + ($root_key:literal, $key:literal, {$($field:ident),* $(, ..)?}) => { $crate::__i18n_enum_variant_values!($root_key, $key, ($($field),*)) }; } @@ -183,8 +193,8 @@ pub mod test { fn translation_data(&self) -> TranslationData { TranslationData::Translatable { - key: self.full_translation_id(), - values: HashMap::new(), + key: Cow::Borrowed(self.full_translation_id()), + values: BTreeMap::new(), } } } From 5e79114c6a4fb642a616c0996b509f99652a9510 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Tue, 16 Sep 2025 09:56:03 -0500 Subject: [PATCH 36/43] Run extract --- apps/ariadne-extract/src/extractor.rs | 39 ++++---- apps/frontend/src/locales/en-US/labrinth.json | 99 +++++++++++++++++++ apps/labrinth/src/models/v3/notifications.rs | 2 +- 3 files changed, 121 insertions(+), 19 deletions(-) diff --git a/apps/ariadne-extract/src/extractor.rs b/apps/ariadne-extract/src/extractor.rs index be16482f03..c4c3b7d2a3 100644 --- a/apps/ariadne-extract/src/extractor.rs +++ b/apps/ariadne-extract/src/extractor.rs @@ -404,36 +404,38 @@ impl Parse for I18nEnumVariant { let variant_name = input.parse()?; let mut transparent = None; + let mut fields = Punctuated::new(); let fields_type; - let fields = if input.peek(Token![!]) || input.peek(Token![=>]) { + if input.peek(Token![!]) || input.peek(Token![=>]) { // Immediate => also flows into this case for a better error message fields_type = FieldsType::Unit; input.parse::()?; - Punctuated::new() } else { let content; if input.peek(token::Brace) { fields_type = FieldsType::Named; braced!(content in input); - if content.peek(Token![..]) { - content.parse::()?; - Punctuated::new() - } else { - content.parse_terminated(Ident::parse, Token![,])? - } } else { fields_type = FieldsType::Tuple; parenthesized!(content in input); - if content.peek(Token![..]) { - content.parse::()?; - Punctuated::new() - } else if content.peek(kw::transparent) { - transparent = Some(content.parse()?); - content.parse::()?; - Punctuated::new() - } else { - content.parse_terminated(Ident::parse, Token![,])? + } + 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::()?; } }; @@ -462,7 +464,7 @@ impl I18nEnumVariant { return (format_string, errors); } - let known_names = if matches!(self.fields_type, FieldsType::Named) { + let known_names = if self.fields_type == FieldsType::Named { self.fields.iter().map(Ident::to_string).collect() } else { HashSet::new() @@ -578,6 +580,7 @@ impl I18nEnumVariant { } } +#[derive(Eq, PartialEq)] enum FieldsType { Unit, Tuple, diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json index b08c81c3e3..061e174b77 100644 --- a/apps/frontend/src/locales/en-US/labrinth.json +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -458,6 +458,105 @@ "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.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.payment_failed": { + "message": "A payment on your account failed. Please update your billing information." + }, + "labrinth.notification.body.project_update": { + "message": "The project {project_id} has released a new version: {version_id}" + }, + "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.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.payment_failed": { + "message": "Payment failed" + }, + "labrinth.notification.type.project_update": { + "message": "A project you follow has been updated!" + }, + "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.warning.file_validation.invalid_mrpack": { "message": "Invalid modpack file. You must upload a valid .mrpack file." }, diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs index d9a582e9dd..eddbb101a1 100644 --- a/apps/labrinth/src/models/v3/notifications.rs +++ b/apps/labrinth/src/models/v3/notifications.rs @@ -207,7 +207,7 @@ pub enum NotificationBody { i18n_enum!( NotificationBody, - root_key: "labrinth.notification.type", + root_key: "labrinth.notification.body", ProjectUpdate { project_id, version_id } => "project_update", TeamInvite { role, .. } => "team_invite", OrganizationInvite { role, .. } => "organization_invite", From 6c7f8cd3e03899a0b0afde7312ae1ba107388091 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Tue, 16 Sep 2025 13:54:43 -0500 Subject: [PATCH 37/43] Fix failing test --- apps/ariadne-extract/src/extractor.rs | 2 +- packages/ariadne/src/i18n.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ariadne-extract/src/extractor.rs b/apps/ariadne-extract/src/extractor.rs index c4c3b7d2a3..667a472414 100644 --- a/apps/ariadne-extract/src/extractor.rs +++ b/apps/ariadne-extract/src/extractor.rs @@ -432,7 +432,7 @@ impl Parse for I18nEnumVariant { } fields.push_punct(content.parse()?); } - }; + } if fields_type == FieldsType::Named && content.peek(Token![,]) { content.parse::()?; content.parse::()?; diff --git a/packages/ariadne/src/i18n.rs b/packages/ariadne/src/i18n.rs index 64c3f19501..0ef7f78d55 100644 --- a/packages/ariadne/src/i18n.rs +++ b/packages/ariadne/src/i18n.rs @@ -18,7 +18,7 @@ pub enum TranslationData { Literal(String), Translatable { key: Cow<'static, str>, - #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] values: BTreeMap, TranslationData>, }, } From 97408bb529c4cc612e7d1cb8bdfcbd747de16179 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Wed, 17 Sep 2025 08:29:49 -0500 Subject: [PATCH 38/43] Migrate ProjectStatus to I18nEnum --- Cargo.lock | 1 + apps/ariadne-extract/.prettierignore | 1 + apps/ariadne-extract/Cargo.toml | 1 + apps/ariadne-extract/package.json | 11 ++ apps/ariadne-extract/src/extractor.rs | 114 +++++++++++++----- apps/frontend/src/locales/en-US/labrinth.json | 30 +++++ apps/labrinth/src/models/v3/projects.rs | 73 +++++------ apps/labrinth/src/queue/moderation.rs | 5 +- apps/labrinth/src/queue/payouts.rs | 6 +- apps/labrinth/src/routes/v3/projects.rs | 9 +- apps/labrinth/src/routes/v3/statistics.rs | 8 +- .../src/search/indexing/local_import.rs | 2 +- 12 files changed, 177 insertions(+), 84 deletions(-) create mode 100644 apps/ariadne-extract/.prettierignore create mode 100644 apps/ariadne-extract/package.json diff --git a/Cargo.lock b/Cargo.lock index 0fd0669235..08bce651d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -492,6 +492,7 @@ name = "ariadne-extract" version = "0.1.0" dependencies = [ "clap", + "derive_more 2.0.1", "miette", "proc-macro2", "serde", 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 index 4682937bbc..ddc5dfe6fd 100644 --- a/apps/ariadne-extract/Cargo.toml +++ b/apps/ariadne-extract/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true [dependencies] clap.workspace = true thiserror.workspace = true +derive_more = { workspace = true, features = ["display"] } serde_json.workspace = true serde.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/extractor.rs b/apps/ariadne-extract/src/extractor.rs index 667a472414..e0b5a089da 100644 --- a/apps/ariadne-extract/src/extractor.rs +++ b/apps/ariadne-extract/src/extractor.rs @@ -1,10 +1,11 @@ use crate::error::Result; -use serde::{Serialize}; -use std::collections::{BTreeMap, HashMap, HashSet}; +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 proc_macro2::Span; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; use syn::spanned::Spanned; @@ -24,7 +25,7 @@ pub struct TranslationEntry { impl TranslationEntry { pub fn new(message: String, key_span: Span) -> Self { - Self { message, key_span, } + Self { message, key_span } } } @@ -168,14 +169,19 @@ 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)), + Self::Present { + message, + display_attribute_type, + } => Some((message, *display_attribute_type)), } } } -#[derive(PartialEq, Eq, Copy, Clone)] +#[derive(PartialEq, Eq, Copy, Clone, Display)] enum DisplayAttributeType { + #[display("#[error]")] ThiserrorError, + #[display("#[display]")] DeriveMoreDisplay, } @@ -196,6 +202,7 @@ impl Visit<'_> for FileExtractor<'_> { 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| { @@ -210,6 +217,26 @@ impl Visit<'_> for FileExtractor<'_> { } 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!( + "expected {old_type} but found {display_attribute_type} (for consistency)" + ), + ); + 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 @@ -258,10 +285,25 @@ impl Visit<'_> for FileExtractor<'_> { if ignored_variants.contains(&variant.ident) { continue; } - variants.entry(variant.ident.clone()) - .or_insert_with(|| EnumMessage::Absent { - variant_span: variant.span(), - }); + 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); @@ -300,35 +342,49 @@ impl Visit<'_> for FileExtractor<'_> { if variant.transparent.is_some() { continue; } - let Some(message_literal) = messages.get(&variant.variant_name) else { + let Some(message_literal) = + messages.get(&variant.variant_name) + else { continue; }; let (message, errors) = message_literal .as_option() - .map(|(message, display_attribute_type)| { - variant.transform_format_string( - message.value(), - display_attribute_type, - ) - }) - .unwrap_or_else(|| ("".into(), vec![format!("no default message specified for variant {}", variant.variant_name)])); - let duplicate_key = match self.extractor.output.entry(format!("{}.{}", root_key, variant.key.value())) { + .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())); + entry.insert(TranslationEntry::new( + message, + variant.key.span(), + )); None - }, + } Entry::Occupied(entry) => { - (*entry.get().message != message).then(|| (entry.key().clone(), entry.get().key_span)) + (*entry.get().message != message).then(|| { + (entry.key().clone(), entry.get().key_span) + }) } }; - if let Some((duplicate_key, original_span)) = duplicate_key { + if let Some((duplicate_key, original_span)) = duplicate_key + { let mut error = syn::Error::new( variant.key.span(), - format!("duplicate variant key {}", duplicate_key) + format!("duplicate variant key {}", duplicate_key), ); error.combine(syn::Error::new( original_span, - "originally used here" + "originally used here", )); self.extractor.add_error(&self.file, error); } @@ -337,8 +393,12 @@ impl Visit<'_> for FileExtractor<'_> { &self.file, syn::Error::new( match message_literal { - EnumMessage::Absent { variant_span } => *variant_span, - EnumMessage::Present { message, .. } => message.span(), + EnumMessage::Absent { variant_span } => { + *variant_span + } + EnumMessage::Present { + message, .. + } => message.span(), }, error, ), diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json index 061e174b77..59301c2c15 100644 --- a/apps/frontend/src/locales/en-US/labrinth.json +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -557,6 +557,36 @@ "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." }, diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs index b0a8ecb9b6..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,23 +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")] -// TODO: Make translatable and remove as_friendly_str pub enum ProjectStatus { + #[display("Listed")] Approved, Archived, Rejected, Draft, Unlisted, + #[display("Under review")] Processing, Withheld, Scheduled, @@ -441,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 { @@ -461,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/moderation.rs b/apps/labrinth/src/queue/moderation.rs index dce5dafc2d..8251843fbc 100644 --- a/apps/labrinth/src/queue/moderation.rs +++ b/apps/labrinth/src/queue/moderation.rs @@ -675,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 b98eb3d232..73dccd0d2c 100644 --- a/apps/labrinth/src/queue/payouts.rs +++ b/apps/labrinth/src/queue/payouts.rs @@ -879,8 +879,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| { @@ -902,7 +902,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/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 3d1c1bdf6b..1d9da116f9 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -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, ) @@ -445,10 +445,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 diff --git a/apps/labrinth/src/routes/v3/statistics.rs b/apps/labrinth/src/routes/v3/statistics.rs index b04d033eb2..ced8a10d8e 100644 --- a/apps/labrinth/src/routes/v3/statistics.rs +++ b/apps/labrinth/src/routes/v3/statistics.rs @@ -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/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) From 9e441c0fb32920ee4a262005f76e88a7dfe4773b Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Wed, 17 Sep 2025 10:35:19 -0500 Subject: [PATCH 39/43] Migrate CreateError::CustomAuthenticationError to I18nEnum --- apps/ariadne-extract/src/extractor.rs | 7 +++-- apps/frontend/src/locales/en-US/labrinth.json | 12 ++++++-- apps/labrinth/src/routes/v3/create_error.rs | 28 ++++++++++++++++--- .../src/routes/v3/project_creation.rs | 8 +++--- .../src/routes/v3/version_creation.rs | 13 ++++----- 5 files changed, 48 insertions(+), 20 deletions(-) diff --git a/apps/ariadne-extract/src/extractor.rs b/apps/ariadne-extract/src/extractor.rs index e0b5a089da..8dc918f14c 100644 --- a/apps/ariadne-extract/src/extractor.rs +++ b/apps/ariadne-extract/src/extractor.rs @@ -228,7 +228,7 @@ impl Visit<'_> for FileExtractor<'_> { let mut error = syn::Error::new( error_attr.path().span(), format!( - "expected {old_type} but found {display_attribute_type} (for consistency)" + "inconsistent usage of {old_type} and {display_attribute_type}" ), ); error.combine(syn::Error::new( @@ -380,7 +380,10 @@ impl Visit<'_> for FileExtractor<'_> { { let mut error = syn::Error::new( variant.key.span(), - format!("duplicate variant key {}", duplicate_key), + format!( + "duplicate variant key {} with differing messages", + duplicate_key + ), ); error.combine(syn::Error::new( original_span, diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json index 59301c2c15..d7aa374870 100644 --- a/apps/frontend/src/locales/en-US/labrinth.json +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -153,10 +153,16 @@ "message": "Missing project id" }, "labrinth.error.creation.unauthorized": { - "message": "Authentication Error: {cause}" + "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.custom": { - "message": "Authentication Error: {reason}" + "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}" diff --git a/apps/labrinth/src/routes/v3/create_error.rs b/apps/labrinth/src/routes/v3/create_error.rs index ce00e61b99..9254133af5 100644 --- a/apps/labrinth/src/routes/v3/create_error.rs +++ b/apps/labrinth/src/routes/v3/create_error.rs @@ -54,7 +54,7 @@ pub enum CreateError { #[error("Authentication Error: {0}")] Unauthorized(#[from] AuthenticationError), #[error("Authentication Error: {0}")] - CustomAuthenticationError(String), // TODO: Use an I18nEnum instead of a String + CreationAuthenticationError(CreationAuthenticationError), #[error("Image Parsing Error: {0}")] ImageError(#[from] ImageError), } @@ -79,8 +79,8 @@ i18n_enum!( InvalidCategory(category) => "invalid_input.category", InvalidFileType(extension) => "invalid_input.file_type", SlugCollision! => "invalid_input.slug_collision", - Unauthorized(cause) => "unauthorized", - CustomAuthenticationError(reason) => "unauthorized.custom", + Unauthorized(cause_or_reason) => "unauthorized", + CreationAuthenticationError(cause_or_reason) => "unauthorized", ImageError(cause) => "invalid_image", ); @@ -208,6 +208,26 @@ i18n_enum!( 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 { @@ -230,7 +250,7 @@ impl actix_web::ResponseError for CreateError { CreateError::InvalidCategory(..) => StatusCode::BAD_REQUEST, CreateError::InvalidFileType(..) => StatusCode::BAD_REQUEST, CreateError::Unauthorized(..) => StatusCode::UNAUTHORIZED, - CreateError::CustomAuthenticationError(..) => { + CreateError::CreationAuthenticationError(..) => { StatusCode::UNAUTHORIZED } CreateError::SlugCollision => StatusCode::BAD_REQUEST, diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 459be6f6d5..81fc1c4c8c 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -18,7 +18,8 @@ use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::models::threads::ThreadType; use crate::queue::session::AuthQueue; use crate::routes::v3::create_error::{ - CreateError, CreationInvalidInput, MissingValuePart, + CreateError, CreationAuthenticationError, CreationInvalidInput, + MissingValuePart, }; use crate::util::img::upload_image_optimized; use crate::util::routes::read_from_field; @@ -573,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 { diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 37e7f171c5..a40cad50aa 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -24,7 +24,8 @@ use crate::models::teams::ProjectPermissions; use crate::queue::moderation::AutomatedModerationQueue; use crate::queue::session::AuthQueue; use crate::routes::v3::create_error::{ - CreateError, CreationInvalidInput, MissingValuePart, + CreateError, CreationAuthenticationError, CreationInvalidInput, + MissingValuePart, }; use crate::util::routes::read_from_field; use crate::util::validate::validation_errors_to_string; @@ -279,9 +280,8 @@ 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, )); } @@ -721,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, )); } } From d6b8b9eb1686a2a38e556362f820bc9efa63a817 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Wed, 17 Sep 2025 16:38:27 -0500 Subject: [PATCH 40/43] Replace yaserde with serde-xml-rs --- Cargo.lock | 41 +---------------------------- Cargo.toml | 3 +-- apps/labrinth/Cargo.toml | 2 +- apps/labrinth/src/routes/error.rs | 3 +-- apps/labrinth/src/routes/maven.rs | 43 ++++++++++++++----------------- 5 files changed, 23 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 08bce651d6..2b48012d5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4430,6 +4430,7 @@ dependencies = [ "sentry", "sentry-actix", "serde", + "serde-xml-rs", "serde_json", "serde_with", "sha1", @@ -4451,7 +4452,6 @@ dependencies = [ "validator", "webp", "woothee", - "yaserde", "zip", "zxcvbn", ] @@ -7842,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.106", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -11235,33 +11223,6 @@ dependencies = [ "linked-hash-map", ] -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f785831c0e09e0f1a83f917054fd59c088f6561db5b2a42c1c3e1687329325f" -dependencies = [ - "heck 0.5.0", - "log", - "proc-macro2", - "quote", - "serde", - "serde_tokenstream", - "syn 2.0.106", - "xml-rs", -] - [[package]] name = "yaup" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 206a753150..05e7a6e769 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -136,7 +136,7 @@ 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" @@ -181,7 +181,6 @@ 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/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index fb6bdd8f96..f145566049 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -48,7 +48,7 @@ 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 diff --git a/apps/labrinth/src/routes/error.rs b/apps/labrinth/src/routes/error.rs index 6b4e277eb8..a8cd285cd0 100644 --- a/apps/labrinth/src/routes/error.rs +++ b/apps/labrinth/src/routes/error.rs @@ -25,9 +25,8 @@ pub enum ApiError { #[error("Clickhouse Error: {0}")] Clickhouse(#[from] clickhouse::error::Error), - // TODO: Use an I18nEnum instead of a String #[error("Internal server error: {0}")] - Xml(String), + Xml(serde_xml_rs::Error), #[error("Deserialization error: {0}")] Json(#[from] serde_json::Error), diff --git a/apps/labrinth/src/routes/maven.rs b/apps/labrinth/src/routes/maven.rs index b56637469b..3926cba07f 100644 --- a/apps/labrinth/src/routes/maven.rs +++ b/apps/labrinth/src/routes/maven.rs @@ -12,9 +12,9 @@ use crate::queue::session::AuthQueue; 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( @@ -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) { From cb96a4135a83ddf0f6815c31a5cfa78889cb0db4 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Wed, 17 Sep 2025 18:55:13 -0500 Subject: [PATCH 41/43] Run intl:extract --- apps/frontend/src/locales/en-US/labrinth.json | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json index d7aa374870..0a7c1182e8 100644 --- a/apps/frontend/src/locales/en-US/labrinth.json +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -473,6 +473,9 @@ "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." }, @@ -485,12 +488,33 @@ "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." }, @@ -524,6 +548,9 @@ "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!" }, @@ -536,12 +563,33 @@ "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" }, From 8777543f1a7344e37877df3e0006996ba03c41c9 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Fri, 19 Sep 2025 11:48:32 -0500 Subject: [PATCH 42/43] Move ApiError::CustomAuthentication to I18nEnum --- apps/frontend/src/locales/en-US/labrinth.json | 222 ++++++++++++ apps/labrinth/src/auth/checks.rs | 15 +- apps/labrinth/src/routes/error.rs | 333 ++++++++++++++++-- apps/labrinth/src/routes/internal/billing.rs | 226 ++++++------ apps/labrinth/src/routes/internal/flows.rs | 40 +-- apps/labrinth/src/routes/v3/collections.rs | 48 +-- apps/labrinth/src/routes/v3/images.rs | 19 +- apps/labrinth/src/routes/v3/notifications.rs | 17 +- apps/labrinth/src/routes/v3/oauth_clients.rs | 6 +- apps/labrinth/src/routes/v3/organizations.rs | 51 ++- apps/labrinth/src/routes/v3/projects.rs | 227 ++++++------ .../v3/shared_instance_version_creation.rs | 6 +- .../src/routes/v3/shared_instances.rs | 15 +- apps/labrinth/src/routes/v3/teams.rs | 101 +++--- apps/labrinth/src/routes/v3/threads.rs | 6 +- apps/labrinth/src/routes/v3/users.rs | 48 ++- apps/labrinth/src/routes/v3/version_file.rs | 7 +- apps/labrinth/src/routes/v3/versions.rs | 66 ++-- apps/labrinth/src/util/ratelimit.rs | 6 +- packages/ariadne/src/i18n.rs | 36 +- 20 files changed, 937 insertions(+), 558 deletions(-) diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json index 0a7c1182e8..06e1e0f911 100644 --- a/apps/frontend/src/locales/en-US/labrinth.json +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -443,6 +443,228 @@ "labrinth.error.unauthorized.socket": { "message": "Invalid state sent, you probably need to get a new websocket" }, + "labrinth.error.unauthorized.specific.add_to_organization": { + "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_organization_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.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_organization": { + "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_organization": { + "message": "You do not have permission to edit this organization!" + }, + "labrinth.error.unauthorized.specific.edit_organization_description": { + "message": "You do not have the permissions to edit the description of this organization!" + }, + "labrinth.error.unauthorized.specific.edit_organization_icon": { + "message": "You don''t have permission to edit this organization''s icon." + }, + "labrinth.error.unauthorized.specific.edit_organization_name": { + "message": "You do not have the permissions to edit the name of this organization!" + }, + "labrinth.error.unauthorized.specific.edit_organization_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_organization": { + "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_organization_owner_default_project_permissions": { + "message": "You cannot override the project permissions of the organization owner!" + }, + "labrinth.error.unauthorized.specific.refund": { + "message": "You do not have permission to refund a subscription!" + }, + "labrinth.error.unauthorized.specific.remove_organization_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_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" }, diff --git a/apps/labrinth/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs index 334460f9f9..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::error::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/routes/error.rs b/apps/labrinth/src/routes/error.rs index a8cd285cd0..9a194de0d6 100644 --- a/apps/labrinth/src/routes/error.rs +++ b/apps/labrinth/src/routes/error.rs @@ -4,110 +4,79 @@ use crate::models::error::AsApiError; use actix_web::http::StatusCode; use actix_web::{HttpResponse, ResponseError}; use ariadne::i18n_enum; +use thiserror::Error; -#[derive(thiserror::Error, Debug)] +#[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), - - // TODO: Use an I18nEnum instead of a String #[error("Authentication Error: {0}")] - CustomAuthentication(String), - + 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), } @@ -124,7 +93,7 @@ i18n_enum!( Xml(cause) => "xml_error", Json(cause) => "json_error", Authentication(cause) => "unauthorized", - CustomAuthentication(cause) => "unauthorized", + SpecificAuthentication(cause) => "unauthorized", InvalidInput(cause) => "invalid_input", InvalidLoaderField(cause) => "invalid_input", Validation(cause) => "invalid_input.validation", @@ -149,6 +118,298 @@ i18n_enum!( Stripe(cause) => "stripe_error", ); +// TODO: Rename "Organization" to "Org" +#[derive(Clone, Debug, Error)] +pub enum SpecificAuthenticationError { + #[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!" + )] + EditOrganizationSlug, + #[error( + "You do not have the permissions to edit the description of this organization!" + )] + EditOrganizationDescription, + #[error( + "You do not have the permissions to edit the name of this organization!" + )] + EditOrganizationName, + #[error("You do not have permission to edit this organization!")] + EditOrganization, + #[error("You don't have permission to delete this organization!")] + DeleteOrganization, + #[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!")] + AddToOrganization, + #[error("You don't have permission to edit this organization's icon.")] + EditOrganizationIcon, + #[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")] + InviteUsersToOrganization, + #[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!" + )] + OverrideOrganizationOwnerDefaultProjectPermissions, + #[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" + )] + RemoveOrganizationMember, + #[error("You do not have permission to cancel an organization invite")] + CancelOrganizationInvite, + #[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", + 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", + EditOrganizationSlug! => "edit_organization_slug", + EditOrganizationDescription! => "edit_organization_description", + EditOrganizationName! => "edit_organization_name", + EditOrganization! => "edit_organization", + DeleteOrganization! => "delete_organization", + NotProjectOwnerForAddToOrg! => "not_project_owner_for_add_to_org", + AddToOrganization! => "add_to_organization", + EditOrganizationIcon! => "edit_organization_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", + InviteUsersToOrganization! => "invite_users_to_organization", + GiveUserDefaultProjectPermissions! => "give_user_default_project_permissions", + EditTeamMembers! => "edit_team_members", + OverrideOrganizationOwnerDefaultProjectPermissions! => "override_organization_owner_default_project_permissions", + EditTeamOwnership! => "edit_team_ownership", + RemoveOwnerFromTeam! => "remove_owner_from_team", + RemoveTeamMember! => "remove_team_member", + CancelTeamInvite! => "cancel_team_invite", + RemoveOrganizationMember! => "remove_organization_member", + CancelOrganizationInvite! => "cancel_organization_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 { @@ -158,7 +419,7 @@ impl ResponseError for ApiError { ApiError::RedisDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR, ApiError::Clickhouse(..) => StatusCode::INTERNAL_SERVER_ERROR, ApiError::Authentication(..) => StatusCode::UNAUTHORIZED, - ApiError::CustomAuthentication(..) => StatusCode::UNAUTHORIZED, + ApiError::SpecificAuthentication(..) => StatusCode::UNAUTHORIZED, ApiError::Xml(..) => StatusCode::INTERNAL_SERVER_ERROR, ApiError::Json(..) => StatusCode::BAD_REQUEST, ApiError::Search(..) => StatusCode::INTERNAL_SERVER_ERROR, diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index d00e334bd8..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::error::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/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 97354ba242..b437227fc4 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -13,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::error::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; @@ -84,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); } @@ -108,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); @@ -164,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( @@ -1610,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) @@ -1630,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) } @@ -2056,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 { @@ -2084,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/v3/collections.rs b/apps/labrinth/src/routes/v3/collections.rs index eae8c95cae..384a82aa90 100644 --- a/apps/labrinth/src/routes/v3/collections.rs +++ b/apps/labrinth/src/routes/v3/collections.rs @@ -9,7 +9,7 @@ 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::error::ApiError; +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; @@ -114,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(), @@ -138,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 @@ -174,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; @@ -224,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, @@ -290,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, )); } @@ -382,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, @@ -466,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, @@ -525,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/images.rs b/apps/labrinth/src/routes/v3/images.rs index eed4c4ba2e..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::error::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/notifications.rs b/apps/labrinth/src/routes/v3/notifications.rs index 58b117ec1c..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::error::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 17aacf803c..20ad0ed8c7 100644 --- a/apps/labrinth/src/routes/v3/oauth_clients.rs +++ b/apps/labrinth/src/routes/v3/oauth_clients.rs @@ -2,7 +2,7 @@ use std::{collections::HashSet, fmt::Display, sync::Arc}; use crate::file_hosting::FileHostPublicity; use crate::models::ids::OAuthClientId; -use crate::routes::error::ApiError; +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::{ @@ -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 6f1cc82df7..02419d3c88 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -12,7 +12,7 @@ use crate::models::ids::OrganizationId; use crate::models::pats::Scopes; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::queue::session::AuthQueue; -use crate::routes::error::ApiError; +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; @@ -422,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::EditOrganizationDescription, )); } sqlx::query!( @@ -442,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::EditOrganizationName, )); } sqlx::query!( @@ -462,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::EditOrganizationSlug, )); } @@ -532,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::EditOrganization, )) } } else { @@ -590,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::DeleteOrganization, )); } } @@ -752,8 +747,8 @@ pub async fn organization_projects_add( // 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, )); } @@ -821,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::AddToOrganization, )); } Ok(HttpResponse::Ok().finish()) @@ -998,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::AddToOrganization, )); } Ok(HttpResponse::Ok().finish()) @@ -1057,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::EditOrganizationIcon, )); } } @@ -1161,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::EditOrganizationIcon, )); } } diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 45d276ea2f..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::error::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; @@ -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, )); } @@ -522,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, )); } @@ -580,7 +576,7 @@ pub async fn project_edit( edit_project_categories( categories, &perms, - id as db_ids::DBProjectId, + id, false, &mut transaction, ) @@ -588,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, )); } @@ -621,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, )); } @@ -679,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, )); } @@ -714,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::>(); @@ -767,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, )); } @@ -792,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, )); } @@ -813,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, )); } @@ -834,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, )); } @@ -845,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, )); } @@ -868,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, )); } @@ -976,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(); @@ -1224,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() @@ -1244,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?; @@ -1290,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!( @@ -1301,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, @@ -1326,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, @@ -1497,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(), ) @@ -1504,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, @@ -1513,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(), )); } @@ -1526,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, )); } } @@ -1617,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, @@ -1626,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(), )); } @@ -1638,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, )); } } @@ -1723,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, @@ -1741,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, @@ -1754,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, )); } } @@ -1806,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, @@ -1907,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, @@ -1916,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(), )); } @@ -1928,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, )); } } @@ -2070,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, @@ -2079,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(), )); } @@ -2092,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, )); } } @@ -2161,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, @@ -2170,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(), )); } @@ -2183,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, )); } } @@ -2441,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, @@ -2460,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/shared_instance_version_creation.rs b/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs index aabb98177b..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::error::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 653ffaa8d4..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::error::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/teams.rs b/apps/labrinth/src/routes/v3/teams.rs index e9e4169833..852d619dfa 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::error::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::InviteUsersToOrganization, )); } 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::OverrideOrganizationOwnerDefaultProjectPermissions, )); } @@ -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::RemoveOrganizationMember, )); } } 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::CancelOrganizationInvite, )); } } diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index f141dc61ba..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::error::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 674addca57..ec81c8a5db 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -1,7 +1,7 @@ use std::{collections::HashMap, sync::Arc}; use super::oauth_clients::get_user_clients; -use crate::routes::error::ApiError; +use crate::routes::error::{ApiError, SpecificAuthenticationError}; use crate::{ auth::{ filter_visible_collections, filter_visible_projects, @@ -71,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, )); } @@ -444,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, )); } @@ -467,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, )); } @@ -488,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, )); } @@ -526,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 { @@ -564,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, )); } @@ -638,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, )); } @@ -691,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, )); } @@ -732,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, )); } @@ -774,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_file.rs b/apps/labrinth/src/routes/v3/version_file.rs index 6e8d0b3fab..3f17912380 100644 --- a/apps/labrinth/src/routes/v3/version_file.rs +++ b/apps/labrinth/src/routes/v3/version_file.rs @@ -7,7 +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; +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 bbf9825bf4..a3a53829a5 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -23,7 +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; +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, @@ -501,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, @@ -522,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, @@ -561,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, )); } @@ -691,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 { @@ -809,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), @@ -929,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/util/ratelimit.rs b/apps/labrinth/src/util/ratelimit.rs index 1629b9aa92..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::error::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/packages/ariadne/src/i18n.rs b/packages/ariadne/src/i18n.rs index 0ef7f78d55..e2d260841c 100644 --- a/packages/ariadne/src/i18n.rs +++ b/packages/ariadne/src/i18n.rs @@ -26,24 +26,6 @@ pub enum TranslationData { // The extractor in ariadne_extract::extractor needs to be kept up-to-date with this macro definition #[macro_export] macro_rules! i18n_enum { - (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) - } - } - }; - ( $for_enum:ident, root_key: $root_key:literal, @@ -93,6 +75,24 @@ macro_rules! i18n_enum { } } }; + + (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] From 2515dd95cca68b5a67b8ebafeda1d7f56409d066 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Fri, 19 Sep 2025 11:55:21 -0500 Subject: [PATCH 43/43] Change Organization in SpecificAuthenticationError to Org --- apps/frontend/src/locales/en-US/labrinth.json | 22 ++++----- apps/labrinth/src/routes/error.rs | 45 +++++++++---------- apps/labrinth/src/routes/v3/organizations.rs | 18 ++++---- apps/labrinth/src/routes/v3/teams.rs | 8 ++-- 4 files changed, 46 insertions(+), 47 deletions(-) diff --git a/apps/frontend/src/locales/en-US/labrinth.json b/apps/frontend/src/locales/en-US/labrinth.json index 06e1e0f911..ad0ccf5dff 100644 --- a/apps/frontend/src/locales/en-US/labrinth.json +++ b/apps/frontend/src/locales/en-US/labrinth.json @@ -443,13 +443,13 @@ "labrinth.error.unauthorized.socket": { "message": "Invalid state sent, you probably need to get a new websocket" }, - "labrinth.error.unauthorized.specific.add_to_organization": { + "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_organization_invite": { + "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": { @@ -461,7 +461,7 @@ "labrinth.error.unauthorized.specific.delete_message": { "message": "You cannot delete this message!" }, - "labrinth.error.unauthorized.specific.delete_organization": { + "labrinth.error.unauthorized.specific.delete_org": { "message": "You don''t have permission to delete this organization!" }, "labrinth.error.unauthorized.specific.delete_project": { @@ -479,19 +479,19 @@ "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_organization": { + "labrinth.error.unauthorized.specific.edit_org": { "message": "You do not have permission to edit this organization!" }, - "labrinth.error.unauthorized.specific.edit_organization_description": { + "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_organization_icon": { + "labrinth.error.unauthorized.specific.edit_org_icon": { "message": "You don''t have permission to edit this organization''s icon." }, - "labrinth.error.unauthorized.specific.edit_organization_name": { + "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_organization_slug": { + "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": { @@ -590,7 +590,7 @@ "labrinth.error.unauthorized.specific.invalid_master_key": { "message": "Invalid master key" }, - "labrinth.error.unauthorized.specific.invite_users_to_organization": { + "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": { @@ -614,13 +614,13 @@ "labrinth.error.unauthorized.specific.old_password_not_specified": { "message": "You must specify the old password to change your password!" }, - "labrinth.error.unauthorized.specific.override_organization_owner_default_project_permissions": { + "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.refund": { "message": "You do not have permission to refund a subscription!" }, - "labrinth.error.unauthorized.specific.remove_organization_member": { + "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": { diff --git a/apps/labrinth/src/routes/error.rs b/apps/labrinth/src/routes/error.rs index 9a194de0d6..241f2d7e4d 100644 --- a/apps/labrinth/src/routes/error.rs +++ b/apps/labrinth/src/routes/error.rs @@ -118,7 +118,6 @@ i18n_enum!( Stripe(cause) => "stripe_error", ); -// TODO: Rename "Organization" to "Org" #[derive(Clone, Debug, Error)] pub enum SpecificAuthenticationError { #[error("You do not have permission to refund a subscription!")] @@ -156,27 +155,27 @@ pub enum SpecificAuthenticationError { #[error( "You do not have the permissions to edit the slug of this organization!" )] - EditOrganizationSlug, + EditOrgSlug, #[error( "You do not have the permissions to edit the description of this organization!" )] - EditOrganizationDescription, + EditOrgDescription, #[error( "You do not have the permissions to edit the name of this organization!" )] - EditOrganizationName, + EditOrgName, #[error("You do not have permission to edit this organization!")] - EditOrganization, + EditOrg, #[error("You don't have permission to delete this organization!")] - DeleteOrganization, + 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!")] - AddToOrganization, + AddToOrg, #[error("You don't have permission to edit this organization's icon.")] - EditOrganizationIcon, + EditOrgIcon, #[error("You do not have permission to edit this project!")] EditProject, #[error( @@ -270,7 +269,7 @@ pub enum SpecificAuthenticationError { #[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")] - InviteUsersToOrganization, + InviteUsersToOrg, #[error( "You do not have permission to give this user default project permissions." )] @@ -280,7 +279,7 @@ pub enum SpecificAuthenticationError { #[error( "You cannot override the project permissions of the organization owner!" )] - OverrideOrganizationOwnerDefaultProjectPermissions, + 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")] @@ -292,9 +291,9 @@ pub enum SpecificAuthenticationError { #[error( "You do not have permission to remove a member from this organization" )] - RemoveOrganizationMember, + RemoveOrgMember, #[error("You do not have permission to cancel an organization invite")] - CancelOrganizationInvite, + CancelOrgInvite, #[error("You cannot delete this message!")] DeleteMessage, #[error("You do not have permission to get a user from their email!")] @@ -347,14 +346,14 @@ i18n_enum!( NotificationRead! => "notification_read", NotificationDelete! => "notification_delete", SeeOAuthClients! => "see_oauth_clients", - EditOrganizationSlug! => "edit_organization_slug", - EditOrganizationDescription! => "edit_organization_description", - EditOrganizationName! => "edit_organization_name", - EditOrganization! => "edit_organization", - DeleteOrganization! => "delete_organization", + 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", - AddToOrganization! => "add_to_organization", - EditOrganizationIcon! => "edit_organization_icon", + AddToOrg! => "add_to_org", + EditOrgIcon! => "edit_org_icon", EditProject! => "edit_project", EditProjectName! => "edit_project_name", EditProjectSummary! => "edit_project_summary", @@ -384,16 +383,16 @@ i18n_enum!( DeleteSharedInstance! => "delete_shared_instance", DeleteSharedInstanceVersion! => "delete_shared_instance_version", InviteUsersToTeam! => "invite_users_to_team", - InviteUsersToOrganization! => "invite_users_to_organization", + InviteUsersToOrg! => "invite_users_to_org", GiveUserDefaultProjectPermissions! => "give_user_default_project_permissions", EditTeamMembers! => "edit_team_members", - OverrideOrganizationOwnerDefaultProjectPermissions! => "override_organization_owner_default_project_permissions", + OverrideOrgOwnerDefaultProjectPermissions! => "override_org_owner_default_project_permissions", EditTeamOwnership! => "edit_team_ownership", RemoveOwnerFromTeam! => "remove_owner_from_team", RemoveTeamMember! => "remove_team_member", CancelTeamInvite! => "cancel_team_invite", - RemoveOrganizationMember! => "remove_organization_member", - CancelOrganizationInvite! => "cancel_organization_invite", + RemoveOrgMember! => "remove_org_member", + CancelOrgInvite! => "cancel_org_invite", DeleteMessage! => "delete_message", GetUserFromEmail! => "get_user_from_email", EditUserRole! => "edit_user_role", diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 02419d3c88..c121ffd1f0 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -423,7 +423,7 @@ pub async fn organizations_edit( if let Some(description) = &new_organization.description { if !perms.contains(OrganizationPermissions::EDIT_DETAILS) { return Err(ApiError::SpecificAuthentication( - SpecificAuthenticationError::EditOrganizationDescription, + SpecificAuthenticationError::EditOrgDescription, )); } sqlx::query!( @@ -442,7 +442,7 @@ pub async fn organizations_edit( if let Some(name) = &new_organization.name { if !perms.contains(OrganizationPermissions::EDIT_DETAILS) { return Err(ApiError::SpecificAuthentication( - SpecificAuthenticationError::EditOrganizationName, + SpecificAuthenticationError::EditOrgName, )); } sqlx::query!( @@ -461,7 +461,7 @@ pub async fn organizations_edit( if let Some(slug) = &new_organization.slug { if !perms.contains(OrganizationPermissions::EDIT_DETAILS) { return Err(ApiError::SpecificAuthentication( - SpecificAuthenticationError::EditOrganizationSlug, + SpecificAuthenticationError::EditOrgSlug, )); } @@ -530,7 +530,7 @@ pub async fn organizations_edit( Ok(HttpResponse::NoContent().body("")) } else { Err(ApiError::SpecificAuthentication( - SpecificAuthenticationError::EditOrganization, + SpecificAuthenticationError::EditOrg, )) } } else { @@ -587,7 +587,7 @@ pub async fn organization_delete( if !permissions.contains(OrganizationPermissions::DELETE_ORGANIZATION) { return Err(ApiError::SpecificAuthentication( - SpecificAuthenticationError::DeleteOrganization, + SpecificAuthenticationError::DeleteOrg, )); } } @@ -817,7 +817,7 @@ pub async fn organization_projects_add( .await?; } else { return Err(ApiError::SpecificAuthentication( - SpecificAuthenticationError::AddToOrganization, + SpecificAuthenticationError::AddToOrg, )); } Ok(HttpResponse::Ok().finish()) @@ -993,7 +993,7 @@ pub async fn organization_projects_remove( .await?; } else { return Err(ApiError::SpecificAuthentication( - SpecificAuthenticationError::AddToOrganization, + SpecificAuthenticationError::AddToOrg, )); } Ok(HttpResponse::Ok().finish()) @@ -1051,7 +1051,7 @@ pub async fn organization_icon_edit( if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) { return Err(ApiError::SpecificAuthentication( - SpecificAuthenticationError::EditOrganizationIcon, + SpecificAuthenticationError::EditOrgIcon, )); } } @@ -1154,7 +1154,7 @@ pub async fn delete_organization_icon( if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) { return Err(ApiError::SpecificAuthentication( - SpecificAuthenticationError::EditOrganizationIcon, + SpecificAuthenticationError::EditOrgIcon, )); } } diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs index 852d619dfa..ba6b6c3e26 100644 --- a/apps/labrinth/src/routes/v3/teams.rs +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -504,7 +504,7 @@ pub async fn add_team_member( .contains(OrganizationPermissions::MANAGE_INVITES) { return Err(ApiError::SpecificAuthentication( - SpecificAuthenticationError::InviteUsersToOrganization, + SpecificAuthenticationError::InviteUsersToOrg, )); } if !organization_permissions.contains( @@ -739,7 +739,7 @@ pub async fn edit_team_member( .is_some_and(|x| x != ProjectPermissions::all()) { return Err(ApiError::SpecificAuthentication( - SpecificAuthenticationError::OverrideOrganizationOwnerDefaultProjectPermissions, + SpecificAuthenticationError::OverrideOrgOwnerDefaultProjectPermissions, )); } @@ -1145,7 +1145,7 @@ pub async fn remove_team_member( .await?; } else { return Err(ApiError::SpecificAuthentication( - SpecificAuthenticationError::RemoveOrganizationMember, + SpecificAuthenticationError::RemoveOrgMember, )); } } else if Some(delete_member.user_id) @@ -1159,7 +1159,7 @@ pub async fn remove_team_member( DBTeamMember::delete(id, user_id, &mut transaction).await?; } else { return Err(ApiError::SpecificAuthentication( - SpecificAuthenticationError::CancelOrganizationInvite, + SpecificAuthenticationError::CancelOrgInvite, )); } }