From 6d304dad6113135d0fc68c4d2815bfeede779bda Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:09:07 +0100 Subject: [PATCH] refactor: labrinth `ApiError` and error reporting --- apps/labrinth/src/auth/checks.rs | 41 +- apps/labrinth/src/clickhouse/fetch.rs | 25 +- .../models/notifications_template_item.rs | 27 +- .../models/products_tax_identifier_item.rs | 12 +- apps/labrinth/src/lib.rs | 8 +- .../src/models/v3/moderation_notes.rs | 21 +- apps/labrinth/src/models/v3/notifications.rs | 4 +- apps/labrinth/src/queue/analytics/mod.rs | 133 ++- apps/labrinth/src/queue/billing.rs | 416 +++++--- apps/labrinth/src/queue/email.rs | 53 +- apps/labrinth/src/queue/email/templates.rs | 127 ++- apps/labrinth/src/queue/payouts/flow/mod.rs | 5 +- apps/labrinth/src/queue/payouts/flow/mural.rs | 29 +- .../labrinth/src/queue/payouts/flow/paypal.rs | 31 +- .../src/queue/payouts/flow/tremendous.rs | 10 +- apps/labrinth/src/queue/payouts/mod.rs | 200 ++-- apps/labrinth/src/routes/analytics.rs | 37 +- apps/labrinth/src/routes/debug/pprof.rs | 20 +- apps/labrinth/src/routes/internal/admin.rs | 48 +- .../labrinth/src/routes/internal/affiliate.rs | 94 +- .../src/routes/internal/analytics_event.rs | 13 +- .../src/routes/internal/attribution.rs | 115 ++- apps/labrinth/src/routes/internal/billing.rs | 780 ++++++++------ .../src/routes/internal/billing/payments.rs | 255 ++--- .../internal/billing/update_subscriptions.rs | 5 +- .../src/routes/internal/blocked_users.rs | 24 +- apps/labrinth/src/routes/internal/campaign.rs | 11 +- .../src/routes/internal/delphi/mod.rs | 26 +- .../internal/delphi/tech_review_sync.rs | 20 +- .../routes/internal/external_notifications.rs | 93 +- apps/labrinth/src/routes/internal/flows.rs | 814 ++++++++++----- apps/labrinth/src/routes/internal/gdpr.rs | 59 +- apps/labrinth/src/routes/internal/medal.rs | 33 +- .../internal/moderation/external_license.rs | 139 ++- .../src/routes/internal/moderation/mod.rs | 149 ++- .../routes/internal/moderation/tech_review.rs | 78 +- .../internal/moderation/tech_review/global.rs | 8 +- apps/labrinth/src/routes/internal/mural.rs | 2 +- apps/labrinth/src/routes/internal/pats.rs | 125 ++- apps/labrinth/src/routes/internal/search.rs | 10 +- .../src/routes/internal/server_ping.rs | 3 +- apps/labrinth/src/routes/internal/session.rs | 80 +- apps/labrinth/src/routes/internal/statuses.rs | 42 +- apps/labrinth/src/routes/maven.rs | 120 ++- apps/labrinth/src/routes/mod.rs | 211 +--- apps/labrinth/src/routes/updates.rs | 20 +- apps/labrinth/src/routes/v2/notifications.rs | 10 +- apps/labrinth/src/routes/v2/projects.rs | 64 +- apps/labrinth/src/routes/v2/reports.rs | 13 +- apps/labrinth/src/routes/v2/statistics.rs | 4 +- apps/labrinth/src/routes/v2/tags.rs | 19 +- apps/labrinth/src/routes/v2/teams.rs | 13 +- apps/labrinth/src/routes/v2/threads.rs | 4 +- apps/labrinth/src/routes/v2/users.rs | 32 +- apps/labrinth/src/routes/v2/version_file.rs | 28 +- apps/labrinth/src/routes/v2/versions.rs | 26 +- apps/labrinth/src/routes/v2_reroute.rs | 2 +- .../routes/v3/analytics_get/facets/fixed.rs | 5 +- .../src/routes/v3/analytics_get/facets/mod.rs | 9 +- .../metrics/affiliate_code_conversions.rs | 11 +- .../metrics/affiliate_code_revenue.rs | 11 +- .../metrics/project_downloads.rs | 25 +- .../analytics_get/metrics/project_playtime.rs | 15 +- .../analytics_get/metrics/project_revenue.rs | 11 +- .../v3/analytics_get/metrics/project_views.rs | 15 +- .../src/routes/v3/analytics_get/mod.rs | 146 ++- .../src/routes/v3/analytics_get/old.rs | 105 +- apps/labrinth/src/routes/v3/blocked_users.rs | 48 +- apps/labrinth/src/routes/v3/collections.rs | 202 ++-- apps/labrinth/src/routes/v3/content/mod.rs | 4 +- apps/labrinth/src/routes/v3/friends.rs | 124 ++- apps/labrinth/src/routes/v3/images.rs | 144 +-- apps/labrinth/src/routes/v3/limits.rs | 22 +- apps/labrinth/src/routes/v3/notifications.rs | 119 ++- apps/labrinth/src/routes/v3/oauth_clients.rs | 179 ++-- apps/labrinth/src/routes/v3/organizations.rs | 560 +++++++---- apps/labrinth/src/routes/v3/payouts.rs | 302 +++--- .../src/routes/v3/project_creation.rs | 17 +- .../src/routes/v3/project_creation/new.rs | 4 +- apps/labrinth/src/routes/v3/projects.rs | 949 +++++++++++------- apps/labrinth/src/routes/v3/reports.rs | 238 +++-- apps/labrinth/src/routes/v3/statistics.rs | 13 +- apps/labrinth/src/routes/v3/tags.rs | 51 +- apps/labrinth/src/routes/v3/teams.rs | 526 ++++++---- apps/labrinth/src/routes/v3/threads.rs | 196 ++-- apps/labrinth/src/routes/v3/users.rs | 402 +++++--- apps/labrinth/src/routes/v3/version_file.rs | 155 ++- apps/labrinth/src/routes/v3/versions.rs | 311 ++++-- .../src/search/backend/elasticsearch/mod.rs | 57 +- .../src/search/backend/typesense/mod.rs | 22 +- apps/labrinth/src/search/mod.rs | 9 +- apps/labrinth/src/util/avalara1099.rs | 39 +- apps/labrinth/src/util/captcha.rs | 8 +- apps/labrinth/src/util/error.rs | 248 +++++ apps/labrinth/src/util/gotenberg.rs | 11 +- apps/labrinth/src/util/img.rs | 40 +- apps/labrinth/src/util/ratelimit.rs | 15 +- apps/labrinth/src/util/routes.rs | 34 +- apps/labrinth/src/util/webhook.rs | 48 +- 99 files changed, 6505 insertions(+), 3736 deletions(-) diff --git a/apps/labrinth/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs index ea3d1e259b..daf9d35d00 100644 --- a/apps/labrinth/src/auth/checks.rs +++ b/apps/labrinth/src/auth/checks.rs @@ -11,6 +11,8 @@ use crate::models::projects::{ use crate::models::users::User; use crate::queue::file_scan::get_files_missing_attribution; use crate::routes::ApiError; +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use futures::TryStreamExt; use itertools::Itertools; use xredis::RedisPool; @@ -91,7 +93,8 @@ pub async fn filter_visible_projects( pool, hide_unlisted, ) - .await?; + .await + .wrap_api_err("filtering visible project ids")?; projects.retain(|x| filtered_project_ids.contains(&x.inner.id)); Ok(projects.into_iter().map(|x| x.into()).collect()) } @@ -128,7 +131,8 @@ pub async fn filter_visible_project_ids( if !check_projects.is_empty() { return_projects.extend( filter_enlisted_projects_ids(check_projects, user_option, pool) - .await?, + .await + .wrap_api_err("filtering enlisted projects ids")?, ); } @@ -178,7 +182,8 @@ pub async fn filter_enlisted_projects_ids( } }) .try_collect::>() - .await?; + .await + .wrap_internal_err("fetching query results from database")?; } Ok(return_projects) } @@ -218,7 +223,8 @@ pub async fn filter_visible_versions( pool, redis, ) - .await?; + .await + .wrap_api_err("filtering visible version ids")?; versions.retain(|x| filtered_version_ids.contains(&x.inner.id)); let version_ids: Vec<_> = versions.iter().map(|v| v.inner.id).collect(); @@ -265,10 +271,9 @@ 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::Auth(eyre::eyre!( + "You don't have sufficient permissions to interact with this OAuth application", + ))) }; } @@ -292,7 +297,8 @@ pub async fn filter_visible_version_ids( // Get visible projects- ones we are allowed to see public versions for. let visible_project_ids = filter_visible_project_ids( DBProject::get_many_ids(&project_ids, pool, redis) - .await? + .await + .wrap_api_err("fetching projects for visibility filtering")? .iter() .map(|x| &x.inner) .collect(), @@ -300,12 +306,14 @@ pub async fn filter_visible_version_ids( pool, false, ) - .await?; + .await + .wrap_api_err("filtering visible project IDs")?; // Then, get enlisted versions (Versions that are a part of a project we are a member of) let enlisted_version_ids = filter_enlisted_version_ids(versions.clone(), user_option, pool, redis) - .await?; + .await + .wrap_api_err("filtering enlisted version ids")?; let version_ids: Vec<_> = versions.iter().map(|v| v.id).collect(); let withheld_versions = get_files_missing_attribution(pool, &version_ids) @@ -346,14 +354,16 @@ pub async fn filter_enlisted_version_ids( // Get enlisted projects- ones we are allowed to see hidden versions for. let authorized_project_ids = filter_enlisted_projects_ids( DBProject::get_many_ids(&project_ids, pool, redis) - .await? + .await + .wrap_api_err("fetching projects for membership filtering")? .iter() .map(|x| &x.inner) .collect(), user_option, pool, ) - .await?; + .await + .wrap_api_err("filtering projects by membership")?; for version in versions { if user_option.as_ref().is_some_and(|x| x.role.is_mod()) @@ -428,7 +438,8 @@ pub async fn is_visible_organization( ) -> Result { let members = DBTeamMember::get_from_team_full(organization.team_id, pool, redis) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; // This is meant to match the same projects as the `Project::is_searchable` method, but we're not using // it here because that'd entail pulling in all projects for the organization @@ -437,7 +448,7 @@ pub async fn is_visible_organization( organization.id as database::models::ids::DBOrganizationId ) .fetch_optional(pool) - .await? + .await.wrap_internal_err("checking organization for searchable projects")? .flatten() .unwrap_or(false); diff --git a/apps/labrinth/src/clickhouse/fetch.rs b/apps/labrinth/src/clickhouse/fetch.rs index b0245075b8..d3dfc921ef 100644 --- a/apps/labrinth/src/clickhouse/fetch.rs +++ b/apps/labrinth/src/clickhouse/fetch.rs @@ -1,3 +1,4 @@ +use crate::util::error::Context as _; use std::sync::Arc; use crate::{models::ids::ProjectId, routes::ApiError}; @@ -47,7 +48,10 @@ pub async fn fetch_playtimes( .bind(end_date.timestamp()) .bind(projects.iter().map(|x| x.0).collect::>()); - Ok(query.fetch_all().await?) + Ok(query + .fetch_all() + .await + .wrap_internal_err("querying database for `fetch_playtimes`")?) } // Fetches views as a Vec of ReturnViews @@ -77,7 +81,10 @@ pub async fn fetch_views( .bind(end_date.timestamp()) .bind(projects.iter().map(|x| x.0).collect::>()); - Ok(query.fetch_all().await?) + Ok(query + .fetch_all() + .await + .wrap_internal_err("querying database for `fetch_views`")?) } // Fetches downloads as a Vec of ReturnDownloads @@ -106,7 +113,10 @@ pub async fn fetch_downloads( .bind(end_date.timestamp()) .bind(projects.iter().map(|x| x.0).collect::>()); - Ok(query.fetch_all().await?) + Ok(query + .fetch_all() + .await + .wrap_internal_err("querying database for `fetch_downloads`")?) } pub async fn fetch_countries_downloads( @@ -133,7 +143,9 @@ pub async fn fetch_countries_downloads( .bind(end_date.timestamp()) .bind(projects.iter().map(|x| x.0).collect::>()); - Ok(query.fetch_all().await?) + Ok(query.fetch_all().await.wrap_internal_err( + "querying database for `fetch_countries_downloads`", + )?) } pub async fn fetch_countries_views( @@ -160,5 +172,8 @@ pub async fn fetch_countries_views( .bind(end_date.timestamp()) .bind(projects.iter().map(|x| x.0).collect::>()); - Ok(query.fetch_all().await?) + Ok(query + .fetch_all() + .await + .wrap_internal_err("querying database for `fetch_countries_views`")?) } diff --git a/apps/labrinth/src/database/models/notifications_template_item.rs b/apps/labrinth/src/database/models/notifications_template_item.rs index e5b7911561..0085647b1e 100644 --- a/apps/labrinth/src/database/models/notifications_template_item.rs +++ b/apps/labrinth/src/database/models/notifications_template_item.rs @@ -1,6 +1,8 @@ use crate::database::models::DatabaseError; use crate::models::v3::notifications::{NotificationChannel, NotificationType}; use crate::routes::ApiError; +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use serde::{Deserialize, Serialize}; use xredis::RedisPool; @@ -123,27 +125,40 @@ where html: String, } - let mut redis_conn = redis.connect().await?; + let mut redis_conn = redis + .connect() + .await + .wrap_internal_err("connecting to Redis")?; let redis_key = redis_conn .key() .metadata(TEMPLATES_DYNAMIC_HTML_NAMESPACE, key); - if let Some(body) = - redis_conn.get_deserialized::(&redis_key).await? + if let Some(body) = redis_conn + .get_deserialized::(&redis_key) + .await + .wrap_internal_err("fetching cached data from Redis")? { return Ok(body.html); } drop(redis_conn); - let cached = HtmlBody { html: get().await? }; - let mut redis_conn = redis.connect().await?; + let cached = HtmlBody { + html: get() + .await + .wrap_api_err("generating notification template HTML")?, + }; + let mut redis_conn = redis + .connect() + .await + .wrap_internal_err("connecting to Redis")?; let redis_key = redis_conn .key() .metadata(TEMPLATES_DYNAMIC_HTML_NAMESPACE, key); redis_conn .set_serialized(&redis_key, &cached, Some(HTML_DATA_CACHE_EXPIRY)) - .await?; + .await + .wrap_internal_err("storing cached data in Redis")?; Ok(cached.html) } diff --git a/apps/labrinth/src/database/models/products_tax_identifier_item.rs b/apps/labrinth/src/database/models/products_tax_identifier_item.rs index 506a82ce2e..0a8a5a85e5 100644 --- a/apps/labrinth/src/database/models/products_tax_identifier_item.rs +++ b/apps/labrinth/src/database/models/products_tax_identifier_item.rs @@ -1,6 +1,7 @@ use crate::database::models::ids::{DBProductId, DBProductPriceId}; use crate::models::billing::ProductMetadata; use crate::routes::ApiError; +use crate::util::error::Context as _; pub struct DBProductsTaxIdentifier { pub id: i32, @@ -18,7 +19,8 @@ impl DBProductsTaxIdentifier { product_id.0, ) .fetch_optional(exec) - .await?; + .await + .wrap_internal_err("querying database for `get_product`")?; Ok(maybe_row.map(|row| DBProductsTaxIdentifier { id: row.id, @@ -41,7 +43,8 @@ impl DBProductsTaxIdentifier { price_id.0, ) .fetch_optional(exec) - .await?; + .await + .wrap_internal_err("querying database for `get_price`")?; Ok(maybe_row.map(|row| DBProductsTaxIdentifier { id: row.id, @@ -73,7 +76,7 @@ pub async fn product_info_by_product_price_id( product_price_id.0 as i64, ) .fetch_optional(exec) - .await?; + .await.wrap_internal_err("querying database for `product_info_by_product_price_id`")?; match maybe_row { None => Ok(None), @@ -83,7 +86,8 @@ pub async fn product_info_by_product_price_id( tax_processor_id: row.tax_processor_id, product_id: DBProductId(row.product_id), }, - product_metadata: serde_json::from_value(row.product_metadata)?, + product_metadata: serde_json::from_value(row.product_metadata) + .wrap_request_err("deserializing JSON data")?, })), } } diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index e3ffe16b75..382dd9fc66 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -328,16 +328,16 @@ pub fn app_data_config( labrinth_config: LabrinthConfig, ) { cfg.app_data(web::FormConfig::default().error_handler(|err, _req| { - routes::ApiError::Validation(err.to_string()).into() + routes::ApiError::Request(eyre::eyre!("{err}")).into() })) .app_data(web::PathConfig::default().error_handler(|err, _req| { - routes::ApiError::Validation(err.to_string()).into() + routes::ApiError::Request(eyre::eyre!("{err}")).into() })) .app_data(web::QueryConfig::default().error_handler(|err, _req| { - routes::ApiError::Validation(err.to_string()).into() + routes::ApiError::Request(eyre::eyre!("{err}")).into() })) .app_data(web::JsonConfig::default().error_handler(|err, _req| { - routes::ApiError::Validation(err.to_string()).into() + routes::ApiError::Request(eyre::eyre!("{err}")).into() })) .app_data(web::Data::new(labrinth_config.redis_pool.clone())) .app_data(web::Data::new(labrinth_config.pool.clone())) diff --git a/apps/labrinth/src/models/v3/moderation_notes.rs b/apps/labrinth/src/models/v3/moderation_notes.rs index b6621dc8ea..5488ec0ddf 100644 --- a/apps/labrinth/src/models/v3/moderation_notes.rs +++ b/apps/labrinth/src/models/v3/moderation_notes.rs @@ -4,6 +4,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::routes::ApiError; +use crate::util::error::Context as _; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ModerationNote { @@ -37,9 +38,9 @@ pub struct PatchModerationNote { impl PatchModerationNote { pub fn validate_not_empty(&self) -> Result<(), ApiError> { if self.notes.is_none() && self.user_rating.is_none() { - return Err(ApiError::InvalidInput( - "must specify `notes` or `user_rating`".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "must specify `notes` or `user_rating`", + ))); } Ok(()) @@ -53,16 +54,14 @@ pub fn parse_if_match_header( return Ok(None); }; - let value = value.to_str().map_err(|_| { - ApiError::InvalidInput( - "`if-match` header must be a valid integer".to_string(), - ) - })?; + let value = value.to_str().wrap_request_err( + "`if-match` header must be a valid integer".to_string(), + )?; Some(value.parse::().map_err(|_| { - ApiError::InvalidInput( - "`if-match` header must be a valid integer".to_string(), - ) + ApiError::Request(eyre::eyre!( + "`if-match` header must be a valid integer", + )) })) .transpose() } diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs index 6a655958f9..706bb487cb 100644 --- a/apps/labrinth/src/models/v3/notifications.rs +++ b/apps/labrinth/src/models/v3/notifications.rs @@ -765,8 +765,8 @@ impl NotificationDeliveryStatus { NotificationDeliveryStatus::Delivered => Ok(()), NotificationDeliveryStatus::SkippedPreferences | NotificationDeliveryStatus::SkippedDefault | - NotificationDeliveryStatus::Pending => Err(ApiError::InvalidInput("An error occurred while sending an email to your email address. Please try again later.".to_owned())), - NotificationDeliveryStatus::PermanentlyFailed => Err(ApiError::InvalidInput("This email address doesn't exist! Please try another one.".to_owned())), + NotificationDeliveryStatus::Pending => Err(ApiError::Request(eyre::eyre!("An error occurred while sending an email to your email address. Please try again later.".to_owned()))), + NotificationDeliveryStatus::PermanentlyFailed => Err(ApiError::Request(eyre::eyre!("This email address doesn't exist! Please try another one.".to_owned()))), } } diff --git a/apps/labrinth/src/queue/analytics/mod.rs b/apps/labrinth/src/queue/analytics/mod.rs index 18766e48da..9e1d5744e0 100644 --- a/apps/labrinth/src/queue/analytics/mod.rs +++ b/apps/labrinth/src/queue/analytics/mod.rs @@ -4,6 +4,7 @@ use crate::models::analytics::{ }; use crate::routes::ApiError; use crate::routes::analytics::MINECRAFT_SERVER_PLAYS; +use crate::util::error::Context as _; use dashmap::{DashMap, DashSet}; use std::collections::HashMap; use tracing::trace; @@ -99,25 +100,39 @@ impl AnalyticsQueue { if !affiliate_code_clicks_queue.is_empty() { let mut insert_clicks = client .insert::("affiliate_code_clicks") - .await?; + .await + .wrap_internal_err("writing analytics data to ClickHouse")?; for (_, click_vec) in affiliate_code_clicks_queue { for click in click_vec { - insert_clicks.write(&click).await?; + insert_clicks.write(&click).await.wrap_internal_err( + "writing analytics data to ClickHouse", + )?; } } - insert_clicks.end().await?; + insert_clicks + .end() + .await + .wrap_internal_err("writing analytics data to ClickHouse")?; } if !playtime_queue.is_empty() { - let mut playtimes = client.insert::("playtime").await?; + let mut playtimes = client + .insert::("playtime") + .await + .wrap_internal_err("writing analytics data to ClickHouse")?; for playtime in playtime_queue { - playtimes.write(&playtime).await?; + playtimes.write(&playtime).await.wrap_internal_err( + "writing analytics data to ClickHouse", + )?; } - playtimes.end().await?; + playtimes + .end() + .await + .wrap_internal_err("writing analytics data to ClickHouse")?; } if !minecraft_server_plays_queue.is_empty() { @@ -142,10 +157,15 @@ impl AnalyticsQueue { ) }) .collect::>(); - let mut redis_connection = redis.connect().await?; - - let results = - redis_connection.get_many_typed::(&redis_keys).await?; + let mut redis_connection = redis + .connect() + .await + .wrap_internal_err("connecting to Redis")?; + + let results = redis_connection + .get_many_typed::(&redis_keys) + .await + .wrap_internal_err("connecting to Redis")?; for (idx, count) in results.into_iter().enumerate() { let new_count = if let Some(count) = count { if count >= MINECRAFT_SERVER_PLAYS_LIMIT { @@ -164,18 +184,25 @@ impl AnalyticsQueue { new_count, Some(MINECRAFT_SERVER_PLAYS_EXPIRY as i64), ) - .await?; + .await + .wrap_internal_err("connecting to Redis")?; } let mut plays = client .insert::(MINECRAFT_SERVER_PLAYS) - .await?; + .await + .wrap_internal_err("writing analytics data to ClickHouse")?; for (_, play) in raw_plays { - plays.write(&play).await?; + plays.write(&play).await.wrap_internal_err( + "writing analytics data to ClickHouse", + )?; } - plays.end().await?; + plays + .end() + .await + .wrap_internal_err("writing analytics data to ClickHouse")?; } if !views_queue.is_empty() { @@ -198,10 +225,15 @@ impl AnalyticsQueue { ) }) .collect::>(); - let mut redis_connection = redis.connect().await?; - - let results = - redis_connection.get_many_typed::(&redis_keys).await?; + let mut redis_connection = redis + .connect() + .await + .wrap_internal_err("connecting to Redis")?; + + let results = redis_connection + .get_many_typed::(&redis_keys) + .await + .wrap_internal_err("connecting to Redis")?; for (idx, count) in results.into_iter().enumerate() { let new_count = if let Some((views, monetized)) = raw_views.get_mut(idx) { @@ -226,10 +258,14 @@ impl AnalyticsQueue { let key = &redis_keys[idx]; redis_connection .set(key, new_count, Some(6 * 60 * 60)) - .await?; + .await + .wrap_internal_err("connecting to Redis")?; } - let mut views = client.insert::("views").await?; + let mut views = client + .insert::("views") + .await + .wrap_internal_err("writing analytics data to ClickHouse")?; for (all_views, monetized) in raw_views { for (idx, mut view) in all_views.into_iter().enumerate() { @@ -237,11 +273,16 @@ impl AnalyticsQueue { view.monetized = false; } - views.write(&view).await?; + views.write(&view).await.wrap_internal_err( + "writing analytics data to ClickHouse", + )?; } } - views.end().await?; + views + .end() + .await + .wrap_internal_err("writing analytics data to ClickHouse")?; } if !downloads_queue.is_empty() { @@ -267,10 +308,15 @@ impl AnalyticsQueue { ) }) .collect::>(); - let mut redis_connection = redis.connect().await?; - - let results = - redis_connection.get_many_typed::(&redis_keys).await?; + let mut redis_connection = redis + .connect() + .await + .wrap_internal_err("connecting to Redis")?; + + let results = redis_connection + .get_many_typed::(&redis_keys) + .await + .wrap_internal_err("connecting to Redis")?; for (idx, count) in results.into_iter().enumerate() { let new_count = if let Some(count) = count { if count > 5 { @@ -286,11 +332,18 @@ impl AnalyticsQueue { let key = &redis_keys[idx]; redis_connection .set(key, new_count, Some(6 * 60 * 60)) - .await?; + .await + .wrap_internal_err("connecting to Redis")?; } - let mut transaction = pool.begin().await?; - let mut downloads = client.insert::("downloads").await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; + let mut downloads = client + .insert::("downloads") + .await + .wrap_internal_err("writing analytics data to ClickHouse")?; let mut version_downloads: HashMap = HashMap::new(); let mut project_downloads: HashMap = HashMap::new(); @@ -310,7 +363,9 @@ impl AnalyticsQueue { trace!("writing download {download:?}"); - downloads.write(&download).await?; + downloads.write(&download).await.wrap_internal_err( + "writing analytics data to ClickHouse", + )?; } sqlx::query!( @@ -324,7 +379,8 @@ impl AnalyticsQueue { &version_downloads.values().copied().collect::>(), ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("incrementing version download counts")?; sqlx::query!( " @@ -337,10 +393,17 @@ impl AnalyticsQueue { &project_downloads.values().copied().collect::>(), ) .execute(&mut transaction) - .await?; - - transaction.commit().await?; - downloads.end().await?; + .await + .wrap_internal_err("incrementing project download counts")?; + + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; + downloads + .end() + .await + .wrap_internal_err("writing analytics data to ClickHouse")?; } Ok(()) diff --git a/apps/labrinth/src/queue/billing.rs b/apps/labrinth/src/queue/billing.rs index f982ce48f8..504720adb4 100644 --- a/apps/labrinth/src/queue/billing.rs +++ b/apps/labrinth/src/queue/billing.rs @@ -24,6 +24,7 @@ use crate::routes::internal::billing::payments::*; use crate::util::anrok; use crate::util::archon::ArchonClient; use crate::util::archon::{CreateServerRequest, Specs}; +use crate::util::error::ApiContext as _; use crate::util::error::Context; use ariadne::ids::base62_impl::to_base62; use chrono::Utc; @@ -48,9 +49,14 @@ async fn update_tax_amounts( let mut processed_charges = 0; loop { - let mut txn = pg.begin().await?; + let mut txn = pg + .begin() + .await + .wrap_internal_err("starting database transaction")?; - let charges = DBCharge::get_updateable_lock(&mut txn, 5).await?; + let charges = DBCharge::get_updateable_lock(&mut txn, 5) + .await + .wrap_internal_err("fetching charges from database")?; if charges.is_empty() { info!("No more charges to process"); @@ -82,67 +88,70 @@ async fn update_tax_amounts( charge.price_id, &pg, ) - .await? + .await + .wrap_api_err("fetching price")? .ok_or_else(|| { DatabaseError::Database(sqlx::Error::RowNotFound) - })?; - - let product = - DBProduct::get_price(charge.price_id, &pg) - .await? - .ok_or_else(|| { - DatabaseError::Database( - sqlx::Error::RowNotFound, - ) - })?; + }) + .wrap_internal_err("querying database for `update_tax_amounts`")?; + + let product = DBProduct::get_price(charge.price_id, &pg) + .await + .wrap_internal_err( + "fetching product price from database", + )? + .ok_or_else(|| { + DatabaseError::Database(sqlx::Error::RowNotFound) + }) + .wrap_internal_err( + "finding product price in database", + )?; let stripe_address = 'a: { - let stripe_customer_id = + let stripe_customer = DBUser::get_id(charge.user_id, &pg, &redis) - .await? - .ok_or_else(|| { - ApiError::from(DatabaseError::Database( - sqlx::Error::RowNotFound, - )) - }) - .and_then(|user| { - user.stripe_customer_id.ok_or_else( - || { - ApiError::InvalidInput( - "User has no Stripe customer ID" - .to_owned(), - ) - }, - ) - })? - .parse() - .map_err(|_| { - ApiError::InvalidInput( - "User Stripe customer ID was invalid".to_owned(), - ) - })?; + .await + .wrap_internal_err( + "fetching Stripe customer from database", + )? + .wrap_internal_err( + "finding Stripe customer in database", + )?; + let stripe_customer_id = stripe_customer + .stripe_customer_id + .wrap_request_err( + "finding Stripe customer ID on user", + )? + .parse() + .wrap_request_err( + "parsing user Stripe customer ID", + )?; let customer = stripe::Customer::retrieve( &stripe_client, &stripe_customer_id, &["invoice_settings.default_payment_method"], ) - .await?; + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; // A customer should have a default payment method if they have an active subscription. let payment_method = customer .invoice_settings .and_then(|x| { - x.default_payment_method.and_then(|x| x.into_object()) + x.default_payment_method + .and_then(|x| x.into_object()) }) - .ok_or_else(|| { - ApiError::InvalidInput( - "Customer has no default payment method!".to_string(), - ) + .wrap_request_err_with(|| { + "customer has no default payment method!" + .to_string() })?; - let stripe_address = payment_method.billing_details.address; + let stripe_address = + payment_method.billing_details.address; // Attempt the default payment method's address first, then the customer's address. match stripe_address { @@ -152,18 +161,14 @@ async fn update_tax_amounts( } }; - customer.address.ok_or_else(|| { - ApiError::InvalidInput( - "Couldn't get an address for the Stripe customer" - .to_owned(), - ) + customer.address.wrap_request_err_with(|| { + "couldn't get an address for the Stripe customer" + .to_owned() })? }; let customer_address = - anrok::Address::from_stripe_address( - &stripe_address, - ); + anrok::Address::from_stripe_address(&stripe_address); let tax_amount = anrok_client .create_ephemeral_txn(&anrok::TransactionFields { @@ -179,17 +184,16 @@ async fn update_tax_amounts( customer_id: None, customer_name: None, }) - .await? + .await + .wrap_internal_err("inserting database records for `update_tax_amounts`")? .tax_amount_to_collect; - Result::::Ok( - ProcessedCharge { - new_tax_amount: tax_amount, - product_name: product - .name - .unwrap_or_else(|| "Modrinth".to_owned()), - }, - ) + Result::::Ok(ProcessedCharge { + new_tax_amount: tax_amount, + product_name: product + .name + .unwrap_or_else(|| "Modrinth".to_owned()), + }) }; op_fut.then(move |res| async move { (charge_clone, res) }) @@ -212,11 +216,9 @@ async fn update_tax_amounts( // for this. let subscription_id = - charge.subscription_id.ok_or_else(|| { - ApiError::InvalidInput( - "Charge has no subscription ID".to_owned(), - ) - })?; + charge.subscription_id.wrap_request_err_with( + || "charge has no subscription ID".to_owned(), + )?; NotificationBuilder { body: NotificationBody::TaxNotification { @@ -234,7 +236,8 @@ async fn update_tax_amounts( }, } .insert(charge.user_id, &mut txn, redis) - .await?; + .await + .wrap_internal_err("inserting database records for `update_tax_amounts`")?; charge.tax_amount = new_tax_amount; } @@ -248,10 +251,15 @@ async fn update_tax_amounts( }; charge.tax_last_updated = Some(Utc::now()); - charge.upsert(&mut txn).await?; + charge + .upsert(&mut txn) + .await + .wrap_internal_err("updating subscription id in database")?; } - txn.commit().await?; + txn.commit() + .await + .wrap_internal_err("committing database transaction")?; if processed_charges >= limit { break Ok(()); @@ -287,11 +295,9 @@ async fn update_anrok_transactions( .payment_platform_id .as_ref() .and_then(|x| x.parse().ok()) - .ok_or_else(|| { - ApiError::InvalidInput( - "Refund charge has no or an invalid refund ID" - .to_owned(), - ) + .wrap_request_err_with(|| { + "refund charge has no or an invalid refund ID" + .to_owned() })?; let refund = stripe::Refund::retrieve( @@ -299,15 +305,16 @@ async fn update_anrok_transactions( &refund_id, &["payment_intent.payment_method"], ) - .await?; + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; let pi = refund .payment_intent .and_then(|x| x.into_object()) - .ok_or_else(|| { - ApiError::InvalidInput( - "Refund charge has no payment intent".to_owned(), - ) + .wrap_request_err_with(|| { + "refund charge has no payment intent".to_owned() })?; (pi, anrok::transaction_id_stripe_pyr(&refund_id)) @@ -316,10 +323,8 @@ async fn update_anrok_transactions( .payment_platform_id .as_ref() .and_then(|x| x.parse().ok()) - .ok_or_else(|| { - ApiError::InvalidInput( - "Charge has no payment platform ID".to_owned(), - ) + .wrap_request_err_with(|| { + "charge has no payment platform ID".to_owned() })?; // Attempt retrieving the address via the payment intent's payment method @@ -329,7 +334,10 @@ async fn update_anrok_transactions( &stripe_id, &["payment_method"], ) - .await?; + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; let anrok_id = anrok::transaction_id_stripe_pi(&stripe_id); @@ -341,27 +349,17 @@ async fn update_anrok_transactions( .and_then(|x| x.into_object()) .and_then(|x| x.billing_details.address); - let stripe_customer_id = - DBUser::get_id(c.user_id, &mut *txn, redis) - .await? - .ok_or_else(|| { - ApiError::from(DatabaseError::Database( - sqlx::Error::RowNotFound, - )) - }) - .and_then(|user| { - user.stripe_customer_id.ok_or_else(|| { - ApiError::InvalidInput( - "User has no Stripe customer ID".to_owned(), - ) - }) - })?; + let stripe_customer = DBUser::get_id(c.user_id, &mut *txn, redis) + .await + .wrap_internal_err("fetching Stripe customer from database")? + .wrap_internal_err("finding Stripe customer in database")?; + let stripe_customer_id = stripe_customer + .stripe_customer_id + .wrap_request_err("finding Stripe customer ID on user")?; - let customer_id = stripe_customer_id.parse().map_err(|e| { - ApiError::InvalidInput(format!( - "Charge's Stripe customer ID was invalid ({e})" - )) - })?; + let customer_id = stripe_customer_id + .parse() + .wrap_request_err("parsing request value")?; match pi_stripe_address { Some(address) => { @@ -377,7 +375,10 @@ async fn update_anrok_transactions( let customer = stripe::Customer::retrieve(stripe_client, &customer_id, &[]) - .await?; + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; let Some(address) = customer.address else { // We won't really be able to do anything about this. @@ -388,7 +389,9 @@ async fn update_anrok_transactions( ); c.tax_platform_id = Some("unresolved".to_owned()); - c.upsert(txn).await?; + c.upsert(txn) + .await + .wrap_internal_err("updating customer in database")?; return Ok(()); }; @@ -397,8 +400,12 @@ async fn update_anrok_transactions( }; let tax_id = DBProductsTaxIdentifier::get_price(c.price_id, &mut *txn) - .await? - .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?; + .await + .wrap_api_err("fetching price")? + .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound)) + .wrap_internal_err( + "fetching products tax identifier from database", + )?; // Note: if the tax amount that was charged to the customer is *different* than // what it *should* be NOW, we will take on a loss here. @@ -427,18 +434,18 @@ async fn update_anrok_transactions( match result { Ok(response) => { - let version = response.version.ok_or_else(|| { - ApiError::InvalidInput( - "Anrok response is missing tax transaction version" - .to_owned(), - ) + let version = response.version.wrap_request_err_with(|| { + "anrok response is missing tax transaction version" + .to_owned() })?; c.tax_drift_loss = Some(response.tax_amount_to_collect); c.tax_platform_id = Some(tax_platform_id); c.tax_transaction_version = Some(version); c.tax_platform_accounting_time = Some(c.due); - c.upsert(txn).await?; + c.upsert(txn) + .await + .wrap_internal_err("updating version in database")?; Ok(()) } @@ -449,11 +456,15 @@ async fn update_anrok_transactions( .is_conflict_and(|x| x == "customerAddressCouldNotResolve") { c.tax_platform_id = Some("unresolved".to_owned()); - c.upsert(txn).await?; + c.upsert(txn) + .await + .wrap_internal_err("updating version in database")?; Ok(()) } else { - Err(error.into()) + Err(ApiError::Internal(eyre::eyre!( + "calculating tax with Anrok: {error}" + ))) } } } @@ -464,11 +475,15 @@ async fn update_anrok_transactions( let mut offset = 0; loop { - let mut txn = pg.begin().await?; + let mut txn = pg + .begin() + .await + .wrap_internal_err("starting database transaction")?; let mut charges = DBCharge::get_missing_tax_identifier_lock(&mut txn, offset, 1) - .await?; + .await + .wrap_internal_err("fetching charges from database")?; let Some(c) = charges.pop() else { info!("No more charges to process"); @@ -492,7 +507,9 @@ async fn update_anrok_transactions( offset += 1; } - txn.commit().await?; + txn.commit() + .await + .wrap_internal_err("committing database transaction")?; if processed_charges >= limit { break Ok(()); @@ -512,7 +529,10 @@ pub async fn try_process_user_redeemal( user_redeemal.last_attempt = Some(Utc::now()); user_redeemal.n_attempts += 1; user_redeemal.status = users_redeemals::Status::Processing; - let updated = user_redeemal.update_status_if_pending(pool).await?; + let updated = user_redeemal + .update_status_if_pending(pool) + .await + .wrap_internal_err("updating updated in database")?; if !updated { return Ok(()); @@ -526,7 +546,8 @@ pub async fn try_process_user_redeemal( product_item::QueryProductWithPrices::list_by_product_type( pool, "medal", ) - .await?; + .await + .wrap_internal_err("fetching query product with prices from Redis")?; let Some(product_item::QueryProductWithPrices { id: _product_id, @@ -536,9 +557,9 @@ pub async fn try_process_user_redeemal( name: _, }) = medal_products.pop() else { - return Err(ApiError::Conflict( + return Err(ApiError::Conflict(eyre::eyre!( "Missing Medal subscription product".to_owned(), - )); + ))); }; let ProductMetadata::Medal { @@ -549,31 +570,31 @@ pub async fn try_process_user_redeemal( region, } = metadata else { - return Err(ApiError::Conflict( + return Err(ApiError::Conflict(eyre::eyre!( "Missing or incorrect metadata for Medal subscription".to_owned(), - )); + ))); }; let Some(medal_price) = prices.pop() else { - return Err(ApiError::Conflict( + return Err(ApiError::Conflict(eyre::eyre!( "Missing price for Medal subscription".to_owned(), - )); + ))); }; let (price_duration, price_amount) = match medal_price.prices { Price::OneTime { price: _ } => { - return Err(ApiError::Conflict( + return Err(ApiError::Conflict(eyre::eyre!( "Unexpected metadata for Medal subscription price".to_owned(), - )); + ))); } Price::Recurring { intervals } => { let Some((price_duration, price_amount)) = intervals.into_iter().next() else { - return Err(ApiError::Conflict( + return Err(ApiError::Conflict(eyre::eyre!( "Missing price interval for Medal subscription".to_owned(), - )); + ))); }; (price_duration, price_amount) @@ -585,13 +606,15 @@ pub async fn try_process_user_redeemal( // Get the user's username let user = DBUser::get_id(user_id, pool, redis) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_internal_err("fetching user from database")? + .wrap_not_found_err("resource not found")?; // Send the provision request to Archon. On failure, the redeemal will be "stuck" processing, // and moved back to pending by `index_subscriptions`. - let archon_client = ArchonClient::from_env()?; + let archon_client = ArchonClient::from_env() + .wrap_api_err("executing `ArchonClient::from_env`")?; let server_id = archon_client .create_server(&CreateServerRequest { user_id: to_base62(user_id.0 as u64), @@ -606,13 +629,21 @@ pub async fn try_process_user_redeemal( region, tags: vec!["medal".to_owned()], }) - .await?; + .await + .wrap_internal_err( + "inserting database records for `try_process_user_redeemal`", + )?; - let mut txn = pool.begin().await?; + let mut txn = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; // Build a subscription using this price ID. let subscription = DBUserSubscription { - id: generate_user_subscription_id(&mut txn).await?, + id: generate_user_subscription_id(&mut txn) + .await + .wrap_internal_err("generating user subscription ID")?, user_id, price_id, interval: PriceDuration::FiveDays, @@ -623,12 +654,17 @@ pub async fn try_process_user_redeemal( }), }; - subscription.upsert(&mut txn).await?; + subscription + .upsert(&mut txn) + .await + .wrap_internal_err("generating user subscription ID")?; // Insert an expiring charge, `index_subscriptions` will unprovision the // subscription when expired. DBCharge { - id: generate_charge_id(&mut txn).await?, + id: generate_charge_id(&mut txn) + .await + .wrap_internal_err("generating redeemal charge ID")?, user_id, price_id, amount: price_amount.into(), @@ -651,26 +687,42 @@ pub async fn try_process_user_redeemal( tax_platform_accounting_time: None, } .upsert(&mut txn) - .await?; + .await + .wrap_internal_err("upserting redeemal charge")?; // Update `users_redeemal`, mark subscription as redeemed. user_redeemal.status = users_redeemals::Status::Processed; - user_redeemal.update(&mut txn).await?; + user_redeemal.update(&mut txn).await.wrap_internal_err( + "updating database records for `try_process_user_redeemal`", + )?; - txn.commit().await?; + txn.commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(()) } pub async fn cancel_failing_charges(pool: &PgPool) -> Result<(), ApiError> { - let charges_to_cancel = DBCharge::get_cancellable(pool).await?; + let charges_to_cancel = DBCharge::get_cancellable(pool) + .await + .wrap_internal_err("fetching charge from database")?; for mut charge in charges_to_cancel { charge.status = ChargeStatus::Cancelled; - let mut transaction = pool.begin().await?; - charge.upsert(&mut transaction).await?; - transaction.commit().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; + charge + .upsert(&mut transaction) + .await + .wrap_internal_err("updating transaction in database")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; } Ok(()) @@ -682,7 +734,9 @@ pub async fn process_chargeable_charges( stripe_client: &stripe::Client, anrok_client: &anrok::Client, ) -> Result<(), ApiError> { - let charges_to_do = DBCharge::get_chargeable(pool).await?; + let charges_to_do = DBCharge::get_chargeable(pool) + .await + .wrap_internal_err("fetching charge from database")?; let prices = product_item::DBProductPrice::get_many( &charges_to_do @@ -693,7 +747,8 @@ pub async fn process_chargeable_charges( .collect::>(), pool, ) - .await?; + .await + .wrap_internal_err("fetching product prices from database")?; let users = crate::database::models::DBUser::get_many_ids( &charges_to_do @@ -705,7 +760,8 @@ pub async fn process_chargeable_charges( pool, redis, ) - .await?; + .await + .wrap_internal_err("fetching users from database")?; for mut charge in charges_to_do { let Some(product_price) = @@ -784,9 +840,18 @@ pub async fn process_chargeable_charges( charge.status = ChargeStatus::Failed; } - let mut transaction = pool.begin().await?; - charge.upsert(&mut transaction).await?; - transaction.commit().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; + charge + .upsert(&mut transaction) + .await + .wrap_internal_err("updating transaction in database")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; } Ok(()) @@ -798,7 +863,10 @@ async fn unprovision_subscriptions( ) -> Result<(), ApiError> { info!("Gathering charges to unprovision"); - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let mut clear_cache_users = Vec::new(); // If an active subscription has: @@ -806,7 +874,9 @@ async fn unprovision_subscriptions( // - An expiring charge due now // - A failed charge more than two days ago // It should be unprovisioned - let all_charges = DBCharge::get_unprovision(pool).await?; + let all_charges = DBCharge::get_unprovision(pool) + .await + .wrap_internal_err("fetching charges from database")?; let mut all_subscriptions = user_subscription_item::DBUserSubscription::get_many( @@ -818,7 +888,8 @@ async fn unprovision_subscriptions( .collect::>(), pool, ) - .await?; + .await + .wrap_internal_err("fetching user subscriptions from database")?; let subscription_prices = product_item::DBProductPrice::get_many( &all_subscriptions .iter() @@ -828,7 +899,8 @@ async fn unprovision_subscriptions( .collect::>(), pool, ) - .await?; + .await + .wrap_internal_err("fetching product prices from database")?; let subscription_products = product_item::DBProduct::get_many( &subscription_prices .iter() @@ -838,7 +910,8 @@ async fn unprovision_subscriptions( .collect::>(), pool, ) - .await?; + .await + .wrap_internal_err("fetching products from database")?; let users = DBUser::get_many_ids( &all_subscriptions .iter() @@ -849,7 +922,8 @@ async fn unprovision_subscriptions( pool, redis, ) - .await?; + .await + .wrap_internal_err("fetching users from database")?; for charge in all_charges { debug!("Unprovisioning charge '{}'", to_base62(charge.id.0 as u64)); @@ -898,7 +972,10 @@ async fn unprovision_subscriptions( user.id as DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `unprovision_subscriptions`", + )?; true } @@ -939,7 +1016,10 @@ async fn unprovision_subscriptions( if unprovisioned { subscription.status = SubscriptionStatus::Unprovisioned; - subscription.upsert(&mut transaction).await?; + subscription + .upsert(&mut transaction) + .await + .wrap_internal_err("updating err in database")?; DBUsersSubscriptionsAffiliations::deactivate( subscription.id, @@ -961,8 +1041,12 @@ async fn unprovision_subscriptions( .collect::>(), redis, ) - .await?; - transaction.commit().await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(()) } @@ -972,11 +1056,17 @@ async fn process_redeemals( redis: &RedisPool, ) -> Result<(), ApiError> { // If an offer redeemal has been processing for over 5 minutes, it should be set pending. - UserRedeemal::update_stuck_5_minutes(pool).await?; + UserRedeemal::update_stuck_5_minutes(pool) + .await + .wrap_internal_err( + "updating database records for `process_redeemals`", + )?; // If an offer redeemal is pending, try processing it. // Try processing it. - let pending_redeemals = UserRedeemal::get_pending(pool, 100).await?; + let pending_redeemals = UserRedeemal::get_pending(pool, 100) + .await + .wrap_internal_err("fetching user redeemal from Redis")?; for redeemal in pending_redeemals { if let Err(error) = try_process_user_redeemal(pool, redis, redeemal).await diff --git a/apps/labrinth/src/queue/email.rs b/apps/labrinth/src/queue/email.rs index ff1b9cfb7b..5ee5374b17 100644 --- a/apps/labrinth/src/queue/email.rs +++ b/apps/labrinth/src/queue/email.rs @@ -10,6 +10,8 @@ use crate::models::v3::notifications::{ NotificationChannel, NotificationDeliveryStatus, }; use crate::routes::ApiError; +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use chrono::Utc; use futures::stream::{FuturesUnordered, StreamExt}; use lettre::message::Mailbox; @@ -150,7 +152,13 @@ impl EmailQueue { /// Returns `Ok(false)` if no emails were processed, `Ok(true)` if some were processed. #[instrument(name = "EmailQueue::index", skip_all)] pub async fn index(&self, limit: i64) -> Result { - let transport = self.mailer.lock().await.to_transport().await?; + let transport = self + .mailer + .lock() + .await + .to_transport() + .await + .wrap_internal_err("creating email transport")?; let begin = std::time::Instant::now(); @@ -159,7 +167,8 @@ impl EmailQueue { limit, &self.pg, ) - .await?; + .await + .wrap_internal_err("creating email transport")?; if deliveries.is_empty() { return Ok(false); @@ -171,7 +180,9 @@ impl EmailQueue { // ballooning the error rate. for d in deliveries.iter_mut().filter(|d| d.attempt_count >= 3) { d.status = NotificationDeliveryStatus::PermanentlyFailed; - d.update(&self.pg).await?; + d.update(&self.pg).await.wrap_internal_err( + "marking exhausted email delivery as failed", + )?; } // We hold a FOR UPDATE lock on the rows here, so no other workers are accessing them @@ -183,7 +194,9 @@ impl EmailQueue { .map(|d| d.notification_id) .collect::>(); let notifications = - DBNotification::get_many(¬ification_ids, &self.pg).await?; + DBNotification::get_many(¬ification_ids, &self.pg) + .await + .wrap_internal_err("fetching notifications from database")?; // For all notifications we collected, fill out the template // and send it via SMTP in parallel. @@ -201,11 +214,16 @@ impl EmailQueue { let seq = Arc::clone(&sequential_processing); futures.push(async move { - let mut txn = this.pg.begin().await?; + let mut txn = this + .pg + .begin() + .await + .wrap_internal_err("starting database transaction")?; let maybe_user = DBUser::get_id(notification.user_id, &mut txn, &this.redis) - .await?; + .await + .wrap_internal_err("fetching user from database")?; let Some(mailbox) = maybe_user .and_then(|user| user.email) @@ -268,7 +286,9 @@ impl EmailQueue { }; delivery.attempt_count += 1; - delivery.update(&self.pg).await?; + delivery.update(&self.pg).await.wrap_internal_err( + "updating processed email delivery", + )?; } } @@ -283,7 +303,10 @@ impl EmailQueue { delivery.next_attempt = Utc::now() + chrono::Duration::seconds(EMAIL_RETRY_DELAY_SECONDS); - delivery.update(&self.pg).await?; + delivery + .update(&self.pg) + .await + .wrap_internal_err("scheduling email delivery retry")?; } info!( @@ -302,7 +325,13 @@ impl EmailQueue { user_id: DBUserId, address: Mailbox, ) -> Result { - let transport = self.mailer.lock().await.to_transport().await?; + let transport = self + .mailer + .lock() + .await + .to_transport() + .await + .wrap_internal_err("creating email transport")?; self.send_one_with_transport( txn, transport, @@ -329,7 +358,8 @@ impl EmailQueue { &mut *txn, &self.redis, ) - .await? + .await + .wrap_internal_err("creating email transport")? .into_iter() .find(|t| t.notification_type == notification.notification_type()) else { return Ok(NotificationDeliveryStatus::SkippedDefault); @@ -345,7 +375,8 @@ impl EmailQueue { self.identity.clone(), address, ) - .await?; + .await + .wrap_api_err("executing `templates::build_email`")?; let send_result = transport.send(message).await; diff --git a/apps/labrinth/src/queue/email/templates.rs b/apps/labrinth/src/queue/email/templates.rs index 1ef37e77fe..1ba595cdae 100644 --- a/apps/labrinth/src/queue/email/templates.rs +++ b/apps/labrinth/src/queue/email/templates.rs @@ -11,6 +11,7 @@ use crate::database::models::{ use crate::env::ENV; use crate::models::v3::notifications::NotificationBody; use crate::routes::ApiError; +use crate::util::error::ApiContext as _; use crate::util::error::Context; use crate::util::http::HTTP_CLIENT; use ariadne::ids::base62_impl::to_base62; @@ -135,7 +136,11 @@ pub async fn build_email( ) -> Result { let get_html_body = async { let result: Result, ApiError> = - match template.get_cached_html_data(redis).await? { + match template + .get_cached_html_data(redis) + .await + .wrap_internal_err("fetching email template HTML from Redis")? + { Some(html_body) => Ok(Ok(html_body)), None => { let result = client @@ -149,7 +154,10 @@ pub async fn build_email( if let Ok(ref body) = result { template .set_cached_html_data(body.clone(), redis) - .await?; + .await + .wrap_internal_err( + "updating database records for `build_email`", + )?; } Ok(result) @@ -167,8 +175,10 @@ pub async fn build_email( } = from; let db_user = DBUser::get_id(user_id, &mut *exec, redis) - .await? - .ok_or(DatabaseError::Database(sqlx::Error::RowNotFound))?; + .await + .wrap_internal_err("fetching user from database")? + .ok_or(DatabaseError::Database(sqlx::Error::RowNotFound)) + .wrap_internal_err("fetching user from database")?; let map = [ (USER_NAME, db_user.username), @@ -180,17 +190,24 @@ pub async fn build_email( let (html_body_result, either) = futures::try_join!( get_html_body, collect_template_variables(exec, redis, user_id, body, map) - )?; + ) + .wrap_api_err("executing `collect_template_variables`")?; let mut message_builder = Message::builder().from(Mailbox::new( Some(from_name), - from_address.parse().map_err(MailError::from)?, + from_address + .parse() + .map_err(MailError::from) + .wrap_internal_err("reading HTTP response body")?, )); if let Some((name, address)) = reply_name.zip(reply_address) { message_builder = message_builder.reply_to(Mailbox::new( Some(name), - address.parse().map_err(MailError::from)?, + address + .parse() + .map_err(MailError::from) + .wrap_internal_err("reading HTTP response body")?, )); } @@ -244,21 +261,26 @@ pub async fn build_email( html: Some(html), } => message_builder .multipart(MultiPart::alternative_plain_html(plaintext, html)) - .map_err(MailError::from)?, + .map_err(MailError::from) + .wrap_internal_err( + "executing `MultiPart::alternative_plain_html`", + )?, Body { plaintext: Some(plaintext), html: None, } => message_builder .singlepart(SinglePart::plain(plaintext)) - .map_err(MailError::from)?, + .map_err(MailError::from) + .wrap_internal_err("executing `SinglePart::plain`")?, Body { plaintext: None, html: Some(html), } => message_builder .singlepart(SinglePart::html(html)) - .map_err(MailError::from)?, + .map_err(MailError::from) + .wrap_internal_err("executing `SinglePart::html`")?, Body { plaintext: None, @@ -329,7 +351,10 @@ async fn resolve_report_title( return Ok(title); } - let Some(report) = DBReport::get(report_id, &mut *exec).await? else { + let Some(report) = DBReport::get(report_id, &mut *exec) + .await + .wrap_internal_err("fetching report from database")? + else { return Ok(title); }; let Some(shared_instance_id) = report.shared_instance_id else { @@ -398,8 +423,10 @@ async fn collect_template_variables( exec, redis, ) - .await? - .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))? + .await + .wrap_api_err("fetching email project")? + .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound)) + .wrap_internal_err("fetching project from database")? .inner; map.insert(PROJECT_ID, to_base62(project_id.0)); @@ -423,7 +450,7 @@ async fn collect_template_variables( report_id.0 as i64 ) .fetch_one(&mut *exec) - .await?; + .await.wrap_internal_err("querying database for `collect_template_variables`")?; map.insert(REPORT_ID, to_base62(report_id.0)); map.insert( @@ -433,7 +460,8 @@ async fn collect_template_variables( DBReportId(report_id.0 as i64), result.title, ) - .await?, + .await + .wrap_api_err("executing `resolve_report_title`")?, ); map.insert(REPORT_DATE, date_human_readable(result.created)); Ok(EmailTemplate::Static(map)) @@ -453,7 +481,7 @@ async fn collect_template_variables( report_id.0 as i64 ) .fetch_one(&mut *exec) - .await?; + .await.wrap_internal_err("querying database for `collect_template_variables`")?; map.insert( REPORT_TITLE, @@ -462,7 +490,8 @@ async fn collect_template_variables( DBReportId(report_id.0 as i64), result.title, ) - .await?, + .await + .wrap_api_err("executing `resolve_report_title`")?, ); map.insert(NEWREPORT_ID, to_base62(report_id.0)); Ok(EmailTemplate::Static(map)) @@ -476,7 +505,10 @@ async fn collect_template_variables( project_id.0 as i64 ) .fetch_one(&mut *exec) - .await?; + .await + .wrap_internal_err( + "querying database for `collect_template_variables`", + )?; map.insert(PROJECT_ID, to_base62(project_id.0)); map.insert(PROJECT_NAME, result.name); @@ -494,8 +526,10 @@ async fn collect_template_variables( exec, redis, ) - .await? - .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))? + .await + .wrap_api_err("fetching email project")? + .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound)) + .wrap_internal_err("fetching project from database")? .inner; map.insert(PROJECT_ID, to_base62(project_id.0)); @@ -516,8 +550,10 @@ async fn collect_template_variables( &mut *exec, redis, ) - .await? - .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))? + .await + .wrap_api_err("fetching email project")? + .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound)) + .wrap_internal_err("fetching project from database")? .inner; map.insert(PROJECT_ID, to_base62(project_id.0)); @@ -530,10 +566,14 @@ async fn collect_template_variables( &mut *exec, redis, ) - .await? + .await + .wrap_internal_err("fetching user from database")? .ok_or_else(|| { DatabaseError::Database(sqlx::Error::RowNotFound) - })?; + }) + .wrap_internal_err( + "querying database for `collect_template_variables`", + )?; map.insert(NEWOWNER_TYPE, "user".to_string()); map.insert(NEWOWNER_TYPE_CAPITALIZED, "User".to_string()); @@ -546,10 +586,14 @@ async fn collect_template_variables( &mut *exec, redis, ) - .await? + .await + .wrap_internal_err("fetching organization from database")? .ok_or_else(|| { DatabaseError::Database(sqlx::Error::RowNotFound) - })?; + }) + .wrap_internal_err( + "querying database for `collect_template_variables`", + )?; map.insert(NEWOWNER_TYPE, "organization".to_string()); map.insert( @@ -584,7 +628,10 @@ async fn collect_template_variables( user_id.0 as i64 ) .fetch_one(&mut *exec) - .await?; + .await + .wrap_internal_err( + "querying database for `collect_template_variables`", + )?; map.insert(TEAMINVITE_INVITER_NAME, result.inviter_name); map.insert(TEAMINVITE_PROJECT_NAME, result.project_name); @@ -616,7 +663,10 @@ async fn collect_template_variables( user_id.0 as i64 ) .fetch_one(&mut *exec) - .await?; + .await + .wrap_internal_err( + "querying database for `collect_template_variables`", + )?; map.insert(ORGINVITE_INVITER_NAME, result.inviter_name); map.insert(ORGINVITE_ORG_NAME, result.organization_name); @@ -644,7 +694,10 @@ async fn collect_template_variables( user_id.0 as i64, ) .fetch_one(&mut *exec) - .await?; + .await + .wrap_internal_err( + "querying database for `collect_template_variables`", + )?; map.insert(STATUSCHANGE_PROJECT_NAME, result.project_name); map.insert(STATUSCHANGE_OLD_STATUS, old_status.as_str().to_owned()); @@ -829,7 +882,9 @@ async fn collect_template_variables( key, } => Ok(EmailTemplate::Dynamic { variables: map, - body: dynamic_email_body(redis, title, body_md, key).await?, + body: dynamic_email_body(redis, title, body_md, key) + .await + .wrap_api_err("executing `dynamic_email_body`")?, title: title.to_string(), }), @@ -844,8 +899,10 @@ async fn collect_template_variables( &mut *exec, redis, ) - .await? - .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound))?; + .await + .wrap_internal_err("fetching user from database")? + .ok_or_else(|| DatabaseError::Database(sqlx::Error::RowNotFound)) + .wrap_internal_err("fetching user from database")?; map.insert(SERVERINVITE_INVITER_NAME, inviter.username); map.insert(SERVERINVITE_SERVER_NAME, server_name.clone()); @@ -883,9 +940,11 @@ async fn dynamic_email_body( })) .send() .await - .and_then(|res| res.error_for_status())? + .and_then(|res| res.error_for_status()) + .wrap_internal_err("deserializing HTTP response")? .bytes() - .await? + .await + .wrap_internal_err("deserializing HTTP response")? .as_ref(), ) .wrap_internal_err("email body is not valid UTF-8") diff --git a/apps/labrinth/src/queue/payouts/flow/mod.rs b/apps/labrinth/src/queue/payouts/flow/mod.rs index 08c8d7776a..240b525acf 100644 --- a/apps/labrinth/src/queue/payouts/flow/mod.rs +++ b/apps/labrinth/src/queue/payouts/flow/mod.rs @@ -1,6 +1,7 @@ //! Centralized place where payout rails are defined - their fees, minimum and //! maximum withdraw amounts, and execution logic. +use crate::util::error::ApiContext as _; use eyre::eyre; use modrinth_util::decimal::Decimal2dp; use rust_decimal::Decimal; @@ -59,7 +60,9 @@ impl PayoutsQueue { self, withdrawal.amount, method_details, - &get_method.await?, + &get_method + .await + .wrap_api_err("executing `tremendous::create`")?, ) .await } diff --git a/apps/labrinth/src/queue/payouts/flow/mural.rs b/apps/labrinth/src/queue/payouts/flow/mural.rs index 15ff290e80..6cf2dad6f3 100644 --- a/apps/labrinth/src/queue/payouts/flow/mural.rs +++ b/apps/labrinth/src/queue/payouts/flow/mural.rs @@ -19,7 +19,7 @@ use crate::{ mural::MuralPayoutRequest, }, routes::ApiError, - util::error::Context, + util::error::{ApiContext as _, Context}, }; pub const PLATFORM_FEE: PayoutMethodFee = PayoutMethodFee { @@ -67,7 +67,7 @@ pub(super) async fn create( let mural = queue.muralpay.load(); let mural = mural .as_ref() - .wrap_internal_err("Mural client not available")?; + .wrap_internal_err("required Mural client is not available")?; let method_fee_usd; let forex_usd_to_currency; @@ -158,14 +158,15 @@ pub(super) async fn execute( recipient_info, }: MuralFlow, ) -> Result<(), ApiError> { - let user_email = get_verified_email(user)?; + let user_email = get_verified_email(user) + .wrap_api_err("fetching verified user email")?; let sent_to_method_usd = net_usd + method_fee_usd; let total_fee_usd = method_fee_usd + platform_fee_usd; let mural = queue.muralpay.load(); let mural = mural .as_ref() - .wrap_internal_err("Mural client not available")?; + .wrap_internal_err("required Mural client is not available")?; let payment_statement_doc = queue .create_mural_payment_statement_doc( @@ -175,7 +176,8 @@ pub(super) async fn execute( &recipient_info, gotenberg, ) - .await?; + .await + .wrap_api_err("creating Mural payment statement document")?; let user_id = UserId::from(user.id); let method_id = match &payout_details { @@ -235,13 +237,18 @@ pub(super) async fn execute( Some(format!("User {user_id}")), &[payout], ) - .await - .map_err(|err| match err { - muralpay::MuralError::Api(err) => ApiError::Mural(Box::new(err)), - err => ApiError::Internal( + .await; + let payout_request = match payout_request { + Ok(payout_request) => payout_request, + Err(muralpay::MuralError::Api(err)) => { + return Err(ApiError::Request(eyre::eyre!(Box::new(err)))); + } + Err(err) => { + return Err(ApiError::Internal( eyre!(err).wrap_err("failed to create payout request"), - ), - })?; + )); + } + }; // Once the Mural payout request has been created successfully, // then we *must* commit *a* payout row into the DB, to link the Mural diff --git a/apps/labrinth/src/queue/payouts/flow/paypal.rs b/apps/labrinth/src/queue/payouts/flow/paypal.rs index c8e20e279a..de4b158f34 100644 --- a/apps/labrinth/src/queue/payouts/flow/paypal.rs +++ b/apps/labrinth/src/queue/payouts/flow/paypal.rs @@ -102,21 +102,20 @@ pub(super) async fn execute( if let Some(venmo) = &user.venmo_handle { ("Venmo", "user_handle", venmo.clone(), venmo) } else { - return Err(ApiError::InvalidInput( - "Venmo address has not been set for account!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Venmo address has not been set for account!", + ))); } } else if let Some(paypal_id) = &user.paypal_id { if let Some(paypal_country) = &user.paypal_country { if paypal_country == "US" && method_id != "paypal_us" { - return Err(ApiError::InvalidInput( - "Please use the US PayPal transfer option!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Please use the US PayPal transfer option!", + ))); } else if paypal_country != "US" && method_id == "paypal_us" { - return Err(ApiError::InvalidInput( - "Please use the International PayPal transfer option!" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Please use the International PayPal transfer option!", + ))); } ( @@ -126,14 +125,14 @@ pub(super) async fn execute( user.paypal_email.as_ref().unwrap_or(paypal_id), ) } else { - return Err(ApiError::InvalidInput( - "Please re-link your PayPal account!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Please re-link your PayPal account!", + ))); } } else { - return Err(ApiError::InvalidInput( - "You have not linked a PayPal account!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You have not linked a PayPal account!", + ))); }; let payout_req = json!({ diff --git a/apps/labrinth/src/queue/payouts/flow/tremendous.rs b/apps/labrinth/src/queue/payouts/flow/tremendous.rs index 21f6b7100e..19b00bba95 100644 --- a/apps/labrinth/src/queue/payouts/flow/tremendous.rs +++ b/apps/labrinth/src/queue/payouts/flow/tremendous.rs @@ -1,3 +1,4 @@ +use crate::util::error::ApiContext as _; use chrono::Utc; use eyre::eyre; use modrinth_util::decimal::Decimal2dp; @@ -64,7 +65,8 @@ pub(super) async fn create( "paypal" | "venmo" => { let currency = details.currency.unwrap_or(TremendousCurrency::Usd); let currency_code = currency.to_string(); - let usd_to_currency = usd_to_currency_for(¤cy_code)?; + let usd_to_currency = usd_to_currency_for(¤cy_code) + .wrap_api_err("executing `usd_to_currency_for`")?; let fee = PayoutMethodFee { // If a user withdraws $10: @@ -127,7 +129,8 @@ pub(super) async fn create( } else { TremendousCurrency::Usd.to_string() }; - let usd_to_currency = usd_to_currency_for(¤cy_code)?; + let usd_to_currency = usd_to_currency_for(¤cy_code) + .wrap_api_err("executing `usd_to_currency_for`")?; let currency_to_usd = dec!(1) / usd_to_currency; // no fees @@ -190,7 +193,8 @@ pub(super) async fn execute( pub order: Order, } - let user_email = get_verified_email(user)?; + let user_email = get_verified_email(user) + .wrap_api_err("fetching verified user email")?; let order_req = json!({ "payment": { diff --git a/apps/labrinth/src/queue/payouts/mod.rs b/apps/labrinth/src/queue/payouts/mod.rs index c237f6d624..6efb7477a0 100644 --- a/apps/labrinth/src/queue/payouts/mod.rs +++ b/apps/labrinth/src/queue/payouts/mod.rs @@ -8,6 +8,7 @@ use crate::models::payouts::{ }; use crate::models::projects::MonetizationStatus; use crate::routes::ApiError; +use crate::util::error::ApiContext as _; use crate::util::error::Context; use crate::util::webhook::{ PayoutSourceAlertType, send_slack_payout_source_alert_webhook, @@ -207,19 +208,17 @@ impl PayoutsQueue { .form(&form) .send() .await - .map_err(|_| { - ApiError::Payments( - "Error while authenticating with PayPal".to_string(), - ) - })? + .map_err(|err| eyre::eyre!(err)) + .wrap_failed_dependency_err( + "error while authenticating with PayPal".to_string(), + )? .json() .await - .map_err(|_| { - ApiError::Payments( - "Error while authenticating with PayPal (deser error)" - .to_string(), - ) - })?; + .map_err(|err| eyre::eyre!(err)) + .wrap_failed_dependency_err( + "error while authenticating with PayPal (deser error)" + .to_string(), + )?; let new_creds = PayPalCredentials { access_token: credential.access_token, @@ -244,21 +243,23 @@ impl PayoutsQueue { let credentials = if let Some(credentials) = read.as_ref() { if credentials.expires < Utc::now() { drop(read); - self.refresh_token().await.map_err(|_| { - ApiError::Payments( - "Error while authenticating with PayPal".to_string(), - ) - })? + self.refresh_token() + .await + .map_err(|err| eyre::eyre!(err)) + .wrap_failed_dependency_err( + "error while authenticating with PayPal".to_string(), + )? } else { credentials.clone() } } else { drop(read); - self.refresh_token().await.map_err(|_| { - ApiError::Payments( - "Error while authenticating with PayPal".to_string(), - ) - })? + self.refresh_token() + .await + .map_err(|err| eyre::eyre!(err)) + .wrap_failed_dependency_err( + "error while authenticating with PayPal".to_string(), + )? }; let client = reqwest::Client::new(); @@ -287,17 +288,23 @@ impl PayoutsQueue { .body(body); } - let resp = request.send().await.map_err(|_| { - ApiError::Payments("could not communicate with PayPal".to_string()) - })?; + let resp = request + .send() + .await + .map_err(|err| eyre::eyre!(err)) + .wrap_failed_dependency_err( + "could not communicate with PayPal".to_string(), + )?; let status = resp.status(); - let value = resp.json::().await.map_err(|_| { - ApiError::Payments( + let value = resp + .json::() + .await + .map_err(|err| eyre::eyre!(err)) + .wrap_failed_dependency_err( "could not retrieve PayPal response body".to_string(), - ) - })?; + )?; if !status.is_success() { #[derive(Deserialize)] @@ -318,27 +325,28 @@ impl PayoutsQueue { if error.name == "INSUFFICIENT_FUNDS" { error.message = "We're currently transferring funds to our PayPal account. Please try again in a couple days.".to_string(); } - return Err(ApiError::Payments(format!( + return Err(ApiError::FailedDependency(eyre::eyre!(format!( "error name: {}, message: {}", error.name, error.message - ))); + )))); } if let Ok(error) = serde_json::from_value::(value) { - return Err(ApiError::Payments(format!( + return Err(ApiError::FailedDependency(eyre::eyre!(format!( "error name: {}, message: {}", error.error, error.error_description - ))); + )))); } - return Err(ApiError::Payments( - "could not retrieve PayPal error body".to_string(), - )); + return Err(ApiError::FailedDependency(eyre::eyre!( + "could not retrieve PayPal error body", + ))); } - Ok(serde_json::from_value(value)?) + Ok(serde_json::from_value(value) + .wrap_request_err("deserializing JSON data")?) } pub async fn make_tremendous_request( @@ -359,19 +367,23 @@ impl PayoutsQueue { request = request.json(&body); } - let resp = request.send().await.map_err(|_| { - ApiError::Payments( + let resp = request + .send() + .await + .map_err(|err| eyre::eyre!(err)) + .wrap_failed_dependency_err( "could not communicate with Tremendous".to_string(), - ) - })?; + )?; let status = resp.status(); - let value = resp.json::().await.map_err(|_| { - ApiError::Payments( + let value = resp + .json::() + .await + .map_err(|err| eyre::eyre!(err)) + .wrap_failed_dependency_err( "could not retrieve Tremendous response body".to_string(), - ) - })?; + )?; if !status.is_success() && let Some(obj) = value.as_object() @@ -385,25 +397,25 @@ impl PayoutsQueue { let err = serde_json::from_value::(array.clone()) - .map_err(|_| { - ApiError::Payments( - "could not retrieve Tremendous error json body" - .to_string(), - ) - })?; - - return Err(ApiError::Payments(format!( + .map_err(|err| eyre::eyre!(err)) + .wrap_failed_dependency_err( + "could not retrieve Tremendous error json body" + .to_string(), + )?; + + return Err(ApiError::FailedDependency(eyre::eyre!(format!( "Tremendous error: {} ({:?})", err.message, err.payload - ))); + )))); } - return Err(ApiError::Payments( - "could not retrieve Tremendous error body".to_string(), - )); + return Err(ApiError::FailedDependency(eyre::eyre!( + "could not retrieve Tremendous error body", + ))); } - Ok(serde_json::from_value(value)?) + Ok(serde_json::from_value(value) + .wrap_request_err("deserializing JSON data")?) } pub async fn get_payout_methods( @@ -469,13 +481,17 @@ impl PayoutsQueue { let options = if let Some(options) = read.as_ref() { if options.expires < Utc::now() { drop(read); - refresh_payout_methods(self).await? + refresh_payout_methods(self) + .await + .wrap_api_err("executing `refresh_payout_methods`")? } else { options.clone() } } else { drop(read); - refresh_payout_methods(self).await? + refresh_payout_methods(self) + .await + .wrap_api_err("executing `refresh_payout_methods`")? }; Ok(options.options) @@ -876,12 +892,18 @@ pub async fn make_aditude_request( "interval": interval })) .send() - .await? - .error_for_status()?; + .await + .wrap_internal_err("deserializing HTTP response")? + .error_for_status() + .wrap_internal_err("deserializing HTTP response")?; - let text = request.text().await?; + let text = request + .text() + .await + .wrap_internal_err("reading HTTP response body")?; - let json: Vec = serde_json::from_str(&text)?; + let json: Vec = serde_json::from_str(&text) + .wrap_request_err("deserializing JSON data")?; Ok(json) } @@ -900,7 +922,8 @@ pub async fn process_payout( crate::models::payouts::PayoutStatus::InTransit.as_str(), ) .execute(pool) - .await?; + .await + .wrap_internal_err("writing analytics data to ClickHouse")?; let start: DateTime = DateTime::from_naive_utc_and_offset( (Utc::now() - Duration::days(1)) @@ -915,7 +938,8 @@ pub async fn process_payout( start, ) .fetch_one(pool) - .await?; + .await + .wrap_internal_err("querying database for `process_payout`")?; if results.exists.unwrap_or(false) { return Ok(()); @@ -966,9 +990,12 @@ pub async fn process_payout( .bind(end.timestamp()) .fetch_one::(), ) - .await?; + .await.wrap_internal_err("querying database for `process_payout`")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; struct PayoutMultipliers { sum: u64, @@ -1029,7 +1056,7 @@ pub async fn process_payout( .insert(r.user_id, r.payouts_split); async move { Ok(acc) } }) - .await?; + .await.wrap_internal_err("inserting project org members into database")?; let project_team_members = sqlx::query!( " @@ -1055,7 +1082,7 @@ pub async fn process_payout( async move { Ok(acc) } }, ) - .await?; + .await.wrap_internal_err("inserting project team members into database")?; for project_id in project_ids { let team_members: HashMap = project_team_members @@ -1098,7 +1125,8 @@ pub async fn process_payout( "Yesterday", "1d", ) - .await?; + .await + .wrap_api_err("executing `make_aditude_request`")?; let aditude_amount: Decimal = aditude_res .iter() @@ -1193,9 +1221,12 @@ pub async fn process_payout( &insert_availables[..] ) .execute(&mut transaction) - .await?; + .await.wrap_internal_err("inserting database records for `process_payout`")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(()) } @@ -1230,14 +1261,18 @@ pub async fn index_payouts_notifications( ) -> Result<(), ApiError> { info!("Updating payout notifications"); - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; payouts_values_notifications::synchronize_future_payout_values( &mut transaction, 200, ) - .await?; - let items = payouts_values_notifications::PayoutsValuesNotification::unnotified_users_with_available_payouts_with_limit(&mut transaction, 200).await?; + .await + .wrap_internal_err("executing `payouts_values_notifications::synchronize_future_payout_values`")?; + let items = payouts_values_notifications::PayoutsValuesNotification::unnotified_users_with_available_payouts_with_limit(&mut transaction, 200).await.wrap_internal_err("executing `PayoutsValuesNotification::unnotified_users_with_available_payouts_with_limit`")?; let payout_ref_ids = items.iter().map(|x| x.id).collect::>(); let dates_available = @@ -1250,14 +1285,23 @@ pub async fn index_payouts_notifications( &mut transaction, redis, ) - .await?; + .await + .wrap_internal_err( + "inserting database records for `index_payouts_notifications`", + )?; payouts_values_notifications::PayoutsValuesNotification::set_notified_many( &payout_ref_ids, &mut transaction, ) - .await?; + .await + .wrap_internal_err( + "updating database records for `index_payouts_notifications`", + )?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(()) } diff --git a/apps/labrinth/src/routes/analytics.rs b/apps/labrinth/src/routes/analytics.rs index 5319350e7b..7330f80d59 100644 --- a/apps/labrinth/src/routes/analytics.rs +++ b/apps/labrinth/src/routes/analytics.rs @@ -9,6 +9,7 @@ use crate::queue::analytics::AnalyticsQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::date::get_current_tenths_of_ms; +use crate::util::error::ApiContext as _; use crate::util::error::Context; use crate::util::http::HttpClient; use actix_web::{HttpRequest, HttpResponse}; @@ -84,11 +85,11 @@ pub async fn page_view_ingest( .ok(); let conn_info = req.connection_info().peer_addr().map(|x| x.to_string()); - let url = Url::parse(&url_input.url).map_err(|_| { - ApiError::InvalidInput("invalid page view URL specified!".to_string()) - })?; - let domain = url.host_str().ok_or_else(|| { - ApiError::InvalidInput("invalid page view URL specified!".to_string()) + let url = Url::parse(&url_input.url) + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("invalid page view URL specified!".to_string())?; + let domain = url.host_str().wrap_request_err_with(|| { + "invalid page view URL specified!".to_string() })?; let url_origin = url.origin().ascii_serialization(); @@ -98,9 +99,9 @@ pub async fn page_view_ingest( .any(|origin| origin == "*" || url_origin == *origin); if !is_valid_url_origin { - return Err(ApiError::InvalidInput( - "invalid page view URL specified!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "invalid page view URL specified!", + ))); } let headers = req @@ -158,7 +159,8 @@ pub async fn page_view_ingest( &**pool, &redis, ) - .await?; + .await + .wrap_api_err("fetching project from database")?; if let Some(project) = project { view.project_id = project.inner.id.0 as u64; @@ -209,14 +211,15 @@ pub async fn playtime_ingest( &session_queue, Scopes::PERFORM_ANALYTICS, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let playtimes = playtime_input.0; if playtimes.len() > 2000 { - return Err(ApiError::InvalidInput( - "Too much playtime entered for version!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Too much playtime entered for version!", + ))); } let versions = crate::database::models::DBVersion::get_many( @@ -224,7 +227,8 @@ pub async fn playtime_ingest( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching versions from database")?; let headers = req.headers(); @@ -300,8 +304,9 @@ pub async fn minecraft_server_play_ingest( let project_id = play_input.project_id; let project = DBProject::get(&project_id.to_string(), &**pool, &redis) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_api_err("fetching project from database")? + .wrap_not_found_err("resource not found")?; if project.components.minecraft_server.is_none() { return Err(ApiError::Request(eyre!( diff --git a/apps/labrinth/src/routes/debug/pprof.rs b/apps/labrinth/src/routes/debug/pprof.rs index cf338ee386..89845bb77c 100644 --- a/apps/labrinth/src/routes/debug/pprof.rs +++ b/apps/labrinth/src/routes/debug/pprof.rs @@ -1,7 +1,9 @@ use crate::routes::ApiError; +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use crate::util::guards::admin_key_guard; use actix_web::{HttpResponse, get}; -use eyre::{Context, eyre}; +use eyre::eyre; use prometheus::{IntGauge, Registry}; use std::time::Duration; @@ -21,10 +23,12 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { #[get("/pprof/heap", guard = "admin_key_guard")] pub async fn heap() -> Result { let mut prof_ctl = jemalloc_pprof::PROF_CTL.as_ref().unwrap().lock().await; - require_profiling_activated(&prof_ctl)?; + require_profiling_activated(&prof_ctl) + .wrap_api_err("executing `require_profiling_activated`")?; let pprof = prof_ctl .dump_pprof() - .map_err(|err| ApiError::InvalidInput(err.to_string()))?; + .map_err(|err| eyre::Report::msg(err.to_string())) + .wrap_request_err("processing request")?; Ok(HttpResponse::Ok() .content_type("application/octet-stream") @@ -39,10 +43,12 @@ pub async fn heap() -> Result { #[get("/pprof/heap/flamegraph", guard = "admin_key_guard")] pub async fn flame_graph() -> Result { let mut prof_ctl = jemalloc_pprof::PROF_CTL.as_ref().unwrap().lock().await; - require_profiling_activated(&prof_ctl)?; + require_profiling_activated(&prof_ctl) + .wrap_api_err("executing `require_profiling_activated`")?; let svg = prof_ctl .dump_flamegraph() - .map_err(|err| ApiError::InvalidInput(err.to_string()))?; + .map_err(|err| eyre::Report::msg(err.to_string())) + .wrap_request_err("processing request")?; Ok(HttpResponse::Ok().content_type("image/svg+xml").body(svg)) } @@ -53,8 +59,8 @@ fn require_profiling_activated( if prof_ctl.activated() { Ok(()) } else { - Err(ApiError::InvalidInput( - "Profiling is not activated".to_string(), + Err(ApiError::Request( + eyre::eyre!("Profiling is not activated",), )) } } diff --git a/apps/labrinth/src/routes/internal/admin.rs b/apps/labrinth/src/routes/internal/admin.rs index dcce8286c5..0fa0196156 100644 --- a/apps/labrinth/src/routes/internal/admin.rs +++ b/apps/labrinth/src/routes/internal/admin.rs @@ -9,6 +9,7 @@ use crate::routes::ApiError; use crate::search::SearchBackend; use crate::search::incremental::consume::reindex_project_document; use crate::util::date::get_current_tenths_of_ms; +use crate::util::error::ApiContext as _; use crate::util::error::Context; use crate::util::guards::admin_key_guard; use crate::util::tags::valid_download_tags; @@ -78,12 +79,11 @@ fn parse_download_meta_from_query( for (key, value) in url.query_pairs() { match key.as_ref() { "mr_download_reason" => { - meta.reason = - Some(DownloadReason::from_str(&value).map_err(|_| { - ApiError::Request(eyre!( - "invalid download reason specified" - )) - })?); + meta.reason = Some( + DownloadReason::from_str(&value) + .map_err(|()| eyre::eyre!("invalid download reason")) + .wrap_request_err("parsing download reason")?, + ); } "mr_game_version" => { meta.game_version = Some(value.into_owned()); @@ -92,8 +92,12 @@ fn parse_download_meta_from_query( meta.loader = Some(value.into_owned()); } "mr_dependent_on" => { - meta.dependent_on = - Some(parse_download_meta_version(&value, "dependent_on")?); + meta.dependent_on = Some( + parse_download_meta_version(&value, "dependent_on") + .wrap_api_err( + "executing `parse_download_meta_version`", + )?, + ); } _ => {} } @@ -122,8 +126,8 @@ async fn resolve_download_attribution_version( crate::database::models::DBVersion::get(version_id, pool, redis) .await .wrap_internal_err("failed to fetch download attribution version")? - .ok_or_else(|| { - ApiError::Request(eyre!("invalid `{field}` version specified")) + .wrap_request_err_with(|| { + format!("invalid `{field}` version specified") })?; Ok(version_id.0 as u64) @@ -185,7 +189,8 @@ pub async fn count_download( download_body.url, ) .fetch_optional(pool.as_ref()) - .await? + .await + .wrap_internal_err("fetching version from database")? { (version.id, version.mod_id) } else if let Some(version) = sqlx::query!( @@ -198,18 +203,19 @@ pub async fn count_download( id_option ) .fetch_optional(pool.as_ref()) - .await? + .await + .wrap_internal_err("fetching version from database")? { (version.id, version.mod_id) } else { - return Err(ApiError::InvalidInput( - "Specified version does not exist!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Specified version does not exist!", + ))); }; - let url = url::Url::parse(&download_body.url).map_err(|_| { - ApiError::InvalidInput("invalid download URL specified!".to_string()) - })?; + let url = url::Url::parse(&download_body.url) + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("invalid download URL specified!".to_string())?; let ip = crate::util::ip::convert_to_ip_v6(&download_body.ip) .unwrap_or_else(|_| Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped()); @@ -220,7 +226,8 @@ pub async fn count_download( .map(Some) .wrap_request_err("invalid download meta")? } else { - parse_download_meta_from_query(&url)? + parse_download_meta_from_query(&url) + .wrap_api_err("parsing download metadata from URL")? }; if let Some(meta) = &meta { @@ -250,7 +257,8 @@ pub async fn count_download( meta.as_ref().and_then(|m| m.dependent_on), "dependent_on", ) - .await?; + .await + .wrap_api_err("executing `resolve_download_attribution_version`")?; let download = Download { recorded: get_current_tenths_of_ms(), diff --git a/apps/labrinth/src/routes/internal/affiliate.rs b/apps/labrinth/src/routes/internal/affiliate.rs index 85f6e9ae69..8716dffba1 100644 --- a/apps/labrinth/src/routes/internal/affiliate.rs +++ b/apps/labrinth/src/routes/internal/affiliate.rs @@ -66,8 +66,8 @@ pub async fn ingest_click( let conn_info = req.connection_info().peer_addr().map(|x| x.to_string()); let url = ingest_click.url; - let domain = url.host_str().ok_or_else(|| { - ApiError::InvalidInput("invalid page view URL specified!".to_string()) + let domain = url.host_str().wrap_request_err_with(|| { + "invalid page view URL specified!".to_string() })?; let url_origin = url.origin().ascii_serialization(); @@ -77,9 +77,9 @@ pub async fn ingest_click( .any(|origin| origin == "*" || url_origin == *origin); if !is_valid_url_origin { - return Err(ApiError::InvalidInput( - "invalid page view URL specified!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "invalid page view URL specified!", + ))); } let exists = sqlx::query!( @@ -159,7 +159,8 @@ pub async fn get_all( &session_queue, Scopes::SESSION_ACCESS, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; if user.role.is_admin() { let codes = DBAffiliateCode::get_all(&**pool) @@ -181,9 +182,9 @@ pub async fn get_all( .collect::>(); Ok(web::Json(codes)) } else { - Err(ApiError::CustomAuthentication( - "You do not have permission to view affiliate codes!".to_string(), - )) + Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to view affiliate codes!", + ))) } } @@ -214,16 +215,16 @@ pub async fn create( &session_queue, Scopes::SESSION_ACCESS, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let is_admin = creator.role.is_admin(); let is_affiliate = creator.badges.contains(Badges::AFFILIATE); if !is_admin && !is_affiliate { - return Err(ApiError::CustomAuthentication( - "You do not have permission to create an affiliate code!" - .to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to create an affiliate code!", + ))); } let creator_id = DBUserId::from(creator.id); @@ -239,19 +240,25 @@ pub async fn create( if affiliate_id != creator_id { let Some(_affiliate_user) = - DBUser::get_id(affiliate_id, &**pool, &redis).await? + DBUser::get_id(affiliate_id, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")? else { - return Err(ApiError::CustomAuthentication( - "Affiliate user not found!".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "Affiliate user not found!", + ))); }; } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let affiliate_code_id = crate::database::models::generate_affiliate_code_id(&mut transaction) - .await?; + .await + .wrap_internal_err("generating affiliate code ID")?; let code = DBAffiliateCode { id: affiliate_code_id, @@ -293,13 +300,15 @@ pub async fn get( &session_queue, Scopes::SESSION_ACCESS, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let (affiliate_code_id,) = path.into_inner(); let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id); - if let Some(model) = - DBAffiliateCode::get_by_id(affiliate_code_id, &**pool).await? + if let Some(model) = DBAffiliateCode::get_by_id(affiliate_code_id, &**pool) + .await + .wrap_internal_err("fetching affiliate code from database")? { let is_admin = user.role.is_admin(); let is_owner = model.affiliate == DBUserId::from(user.id); @@ -307,10 +316,10 @@ pub async fn get( if is_admin || is_owner { Ok(web::Json(AffiliateCode::from(model, is_admin))) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -335,30 +344,33 @@ pub async fn delete( &session_queue, Scopes::SESSION_ACCESS, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let (affiliate_code_id,) = path.into_inner(); let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id); - if let Some(model) = - DBAffiliateCode::get_by_id(affiliate_code_id, &**pool).await? + if let Some(model) = DBAffiliateCode::get_by_id(affiliate_code_id, &**pool) + .await + .wrap_internal_err("fetching affiliate code from database")? { let is_admin = user.role.is_admin(); let is_owner = model.affiliate == DBUserId::from(user.id); if is_admin || is_owner { - let result = - DBAffiliateCode::remove(affiliate_code_id, &**pool).await?; + let result = DBAffiliateCode::remove(affiliate_code_id, &**pool) + .await + .wrap_internal_err("deleting affiliate code from database")?; if result.is_some() { Ok(()) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -389,26 +401,28 @@ pub async fn patch( &session_queue, Scopes::SESSION_ACCESS, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let (affiliate_code_id,) = path.into_inner(); let affiliate_code_id = DBAffiliateCodeId::from(affiliate_code_id); let existing_code = DBAffiliateCode::get_by_id(affiliate_code_id, &**pool) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_internal_err("fetching affiliate code from database")? + .wrap_not_found_err("resource not found")?; let is_admin = user.role.is_admin(); let is_owner = existing_code.affiliate == DBUserId::from(user.id); if !is_admin && !is_owner { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } if !is_admin && !user.badges.contains(Badges::AFFILIATE) { - return Err(ApiError::CustomAuthentication( - "You do not have permission to update affiliate codes!".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to update affiliate codes!", + ))); } DBAffiliateCode::update_source_name( diff --git a/apps/labrinth/src/routes/internal/analytics_event.rs b/apps/labrinth/src/routes/internal/analytics_event.rs index a743703da3..5a6bec2b7e 100644 --- a/apps/labrinth/src/routes/internal/analytics_event.rs +++ b/apps/labrinth/src/routes/internal/analytics_event.rs @@ -56,7 +56,8 @@ pub async fn analytics_event_create( &session_queue, Scopes::empty(), ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; if !user.role.is_admin() { @@ -116,7 +117,8 @@ pub async fn analytics_event_edit( &session_queue, Scopes::empty(), ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; if !user.role.is_admin() { @@ -137,7 +139,7 @@ pub async fn analytics_event_edit( .await .wrap_internal_err("failed to update analytics event")?; if !updated { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } DBAnalyticsEvent::clear_cache(&redis) .await @@ -166,7 +168,8 @@ pub async fn analytics_event_delete( &session_queue, Scopes::empty(), ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; if !user.role.is_admin() { @@ -182,7 +185,7 @@ pub async fn analytics_event_delete( .await .wrap_internal_err("failed to delete analytics event")?; if !deleted { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } DBAnalyticsEvent::clear_cache(&redis) .await diff --git a/apps/labrinth/src/routes/internal/attribution.rs b/apps/labrinth/src/routes/internal/attribution.rs index b12e7e931d..444065345c 100644 --- a/apps/labrinth/src/routes/internal/attribution.rs +++ b/apps/labrinth/src/routes/internal/attribution.rs @@ -1,3 +1,4 @@ +use crate::util::error::ApiContext as _; use actix_web::{HttpRequest, delete, get, patch, post, web}; use chrono::{DateTime, Utc}; use eyre::eyre; @@ -125,7 +126,8 @@ pub async fn scan( &session_queue, Scopes::VERSION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let mut version_ids: Vec = body @@ -155,7 +157,7 @@ pub async fn scan( .wrap_internal_err("failed to fetch versions for attribution scan")?; if versions.len() != version_ids.len() { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } let mut project_ids: Vec = @@ -170,7 +172,8 @@ pub async fn scan( &user, "you do not have permission to upload versions to this project", ) - .await?; + .await + .wrap_api_err("validating can upload versions to project")?; } let project_ids = project_ids.iter().map(|id| id.0).collect::>(); @@ -238,7 +241,8 @@ async fn force_scan_file( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let file_id: DBFileId = path.into_inner().into(); let file = sqlx::query!( @@ -256,7 +260,7 @@ async fn force_scan_file( .fetch_optional(pool.as_ref()) .await .wrap_internal_err("failed to fetch attribution scan file")? - .ok_or(ApiError::NotFound)?; + .wrap_not_found_err("resource not found")?; let mut transaction = pool.begin().await.wrap_internal_err( "failed to begin attribution file scan transaction", @@ -320,13 +324,15 @@ pub async fn list( &session_queue, Scopes::VERSION_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let requester_is_mod = user.role.is_mod(); let project = DBProject::get_id(project_id, pool.as_ref(), redis.as_ref()) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_api_err("fetching attribution project")? + .wrap_not_found_err("resource not found")?; let (team_member, organization_team_member) = DBTeamMember::get_for_project_permissions( &project.inner, @@ -651,13 +657,17 @@ pub async fn update_group( &session_queue, Scopes::VERSION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - if !can_edit_attribution_group(pool.as_ref(), group_id, &user).await? { - return Err(ApiError::CustomAuthentication( - "This attribution group cannot be edited".to_string(), - )); + if !can_edit_attribution_group(pool.as_ref(), group_id, &user) + .await + .wrap_api_err("checking edit attribution group")? + { + return Err(ApiError::Auth(eyre::eyre!( + "This attribution group cannot be edited", + ))); } if matches!( @@ -665,15 +675,15 @@ pub async fn update_group( AttributionResolutionKind::GloballyAllowed { .. } ) && !user.role.is_mod() { - return Err(ApiError::CustomAuthentication( - "Only moderators can set globally allowed attributions".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "Only moderators can set globally allowed attributions", + ))); } if body.attribution.moderation_status.is_some() && !user.role.is_mod() { - return Err(ApiError::CustomAuthentication( - "Only moderators can set attribution moderation status".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "Only moderators can set attribution moderation status", + ))); } let mut attribution = body.attribution; @@ -698,11 +708,12 @@ pub async fn update_group( .wrap_internal_err("failed to update attribution group")?; if result.rows_affected() == 0 { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } clear_group_version_cache(pool.as_ref(), redis.as_ref(), &[group_id]) - .await?; + .await + .wrap_api_err("executing `clear_group_version_cache`")?; Ok(()) } @@ -734,7 +745,8 @@ pub async fn delete_groups( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("deleting database records for `delete_groups`")?; let group_ids = body .groups @@ -772,7 +784,8 @@ pub async fn delete_all_groups( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("deleting database records for `delete_all_groups`")?; let project_id = DBProjectId::from(body.project_id).0; let group_ids = sqlx::query_scalar!( @@ -849,7 +862,7 @@ async fn delete_attribution_groups( .wrap_internal_err("failed to delete attribution groups")?; if result.rows_affected() != group_ids.len() as u64 { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } txn.commit().await.wrap_internal_err( @@ -891,14 +904,13 @@ pub async fn assign( &session_queue, Scopes::VERSION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let sha1 = body.sha1.trim().to_lowercase(); if hex_to_bytes(&sha1).is_none() { - return Err(ApiError::InvalidInput( - "invalid sha1 hex string".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!("invalid sha1 hex string",))); } let sha1_bytes = sha1.as_bytes().to_vec(); let project_id: DBProjectId = body.project_id.into(); @@ -916,7 +928,7 @@ pub async fn assign( .fetch_optional(pool.as_ref()) .await .wrap_internal_err("failed to fetch source attribution group")? - .ok_or(ApiError::NotFound)?; + .wrap_not_found_err("resource not found")?; let target_group_exists = sqlx::query_scalar!( " @@ -932,21 +944,23 @@ pub async fn assign( .wrap_internal_err("failed to check target attribution group")?; if !target_group_exists { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } if !can_edit_attribution_group(pool.as_ref(), source_group_id, &user) - .await? + .await + .wrap_api_err("checking edit attribution group")? || !can_edit_attribution_group( pool.as_ref(), body.target_group_id, &user, ) - .await? + .await + .wrap_api_err("checking edit attribution group")? { - return Err(ApiError::CustomAuthentication( - "This attribution group cannot be edited".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "This attribution group cannot be edited", + ))); } let mut txn = pool.begin().await.wrap_internal_err( @@ -986,7 +1000,7 @@ pub async fn assign( .wrap_internal_err("failed to insert assigned attribution file")?; if result.rows_affected() == 0 { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } sqlx::query!( @@ -1020,7 +1034,8 @@ pub async fn assign( project_id, &sha1_bytes, ) - .await?; + .await + .wrap_api_err("executing `clear_project_sha1_version_cache`")?; Ok(()) } @@ -1052,14 +1067,13 @@ pub async fn split( &session_queue, Scopes::VERSION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let sha1 = body.sha1.trim().to_lowercase(); if hex_to_bytes(&sha1).is_none() { - return Err(ApiError::InvalidInput( - "invalid sha1 hex string".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!("invalid sha1 hex string",))); } let sha1_bytes = sha1.as_bytes().to_vec(); let project_id: DBProjectId = body.project_id.into(); @@ -1078,15 +1092,16 @@ pub async fn split( .wrap_internal_err("failed to fetch attribution file to split")?; let Some(existing) = existing else { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; if !can_edit_attribution_group(pool.as_ref(), existing.group_id, &user) - .await? + .await + .wrap_api_err("checking edit attribution group")? { - return Err(ApiError::CustomAuthentication( - "This attribution group cannot be edited".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "This attribution group cannot be edited", + ))); } let mut txn = pool @@ -1138,7 +1153,8 @@ pub async fn split( project_id, &sha1_bytes, ) - .await?; + .await + .wrap_api_err("executing `clear_project_sha1_version_cache`")?; Ok(()) } @@ -1217,7 +1233,7 @@ async fn can_edit_attribution_group( .fetch_optional(pool) .await .wrap_internal_err("failed to fetch attribution group")? - .ok_or(ApiError::NotFound)?; + .wrap_not_found_err("resource not found")?; ensure_can_upload_versions_to_project( pool, @@ -1225,7 +1241,8 @@ async fn can_edit_attribution_group( user, "you do not have permission to edit this attribution group", ) - .await?; + .await + .wrap_api_err("validating can upload versions to project")?; let attribution: Option = group .attribution diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 82242dd145..5b4f971c96 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -25,6 +25,8 @@ use crate::models::users::Badges; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::anrok; +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use ariadne::ids::base62_impl::{parse_base62, to_base62}; use chrono::{Duration, Utc}; @@ -79,7 +81,8 @@ pub async fn products( let products = product_item::QueryProductWithPrices::list_purchaseable( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching query product with prices from Redis")?; let products = products .into_iter() @@ -137,7 +140,8 @@ pub async fn subscriptions( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let db_subscriptions = @@ -146,23 +150,24 @@ pub async fn subscriptions( if user.role.is_admin() { user_id.into() } else { - return Err(ApiError::InvalidInput( - "You cannot see the subscriptions of other users!" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You cannot see the subscriptions of other users!", + ))); } } else { user.id.into() }, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching user subscriptions from database")?; let mut subscriptions = Vec::with_capacity(db_subscriptions.len()); for subscription in db_subscriptions { let next_charge_tax_amount = DBCharge::get_open_subscription(subscription.id, &**pool) - .await? + .await + .wrap_internal_err("fetching charge from database")? .map(|charge| charge.tax_amount); subscriptions.push(UserSubscriptionWithNextChargeTaxAmount { @@ -215,19 +220,25 @@ pub async fn refund_charge( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; 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::Auth(eyre::eyre!( + "You do not have permission to refund a subscription!", + ))); } - if let Some(charge) = DBCharge::get(id.into(), &**pool).await? { - let refunds = DBCharge::get_children(id.into(), &**pool).await?; + if let Some(charge) = DBCharge::get(id.into(), &**pool) + .await + .wrap_internal_err("fetching charge from database")? + { + let refunds = DBCharge::get_children(id.into(), &**pool) + .await + .wrap_internal_err("fetching charges from database")?; let refunds = -refunds .into_iter() .filter_map(|x| match x.status { @@ -249,16 +260,15 @@ pub async fn refund_charge( }; if charge.status != ChargeStatus::Succeeded { - return Err(ApiError::InvalidInput( - "This charge cannot be refunded!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "This charge cannot be refunded!", + ))); } if (refundable - refund_amount) < 0 { - return Err(ApiError::InvalidInput( - "You cannot refund more than the amount of the charge!" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You cannot refund more than the amount of the charge!", + ))); } let (id, net, anrok_result) = if refund_amount == 0 { @@ -287,29 +297,33 @@ pub async fn refund_charge( &payment_platform_id, &["payment_method"], ) - .await?; + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; let Some(billing_address) = pi .payment_method .and_then(|x| x.into_object()) .and_then(|x| x.billing_details.address) else { - return Err(ApiError::InvalidInput( + return Err(ApiError::Request(eyre::eyre!( "Couldn't retrieve billing address for payment method!" .to_owned(), - )); + ))); }; let tax_id = product_info_by_product_price_id( charge.price_id, &**pool, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "Could not find product tax info for price ID!" - .to_owned(), - ) + .await + .wrap_api_err( + "executing `product_info_by_product_price_id`", + )? + .wrap_request_err_with(|| { + "could not find product tax info for price ID!" + .to_owned() })? .tax_identifier .tax_processor_id; @@ -326,10 +340,10 @@ pub async fn refund_charge( .zip(charge.tax_transaction_version) .zip(charge.tax_platform_accounting_time) else { - return Err(ApiError::InvalidInput( + return Err(ApiError::Request(eyre::eyre!( "Charge is missing full tax information. Please wait for the original charge to be synchronized with the tax processor." .to_owned(), - )); + ))); }; let refund = stripe::Refund::create( @@ -344,7 +358,10 @@ pub async fn refund_charge( ..Default::default() }, ) - .await?; + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; let anrok_txn_result = anrok_client.negate_or_create_partial_negation( original_tax_platform_id, @@ -373,24 +390,28 @@ pub async fn refund_charge( Some(anrok_txn_result), ) } else { - return Err(ApiError::InvalidInput( - "Charge does not have attached payment id!" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Charge does not have attached payment id!", + ))); } } PaymentPlatform::None => { - return Err(ApiError::InvalidInput( + return Err(ApiError::Request(eyre::eyre!( "This charge was not processed via a payment platform." .to_owned(), - )); + ))); } } }; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; - let charge_id = generate_charge_id(&mut transaction).await?; + let charge_id = generate_charge_id(&mut transaction) + .await + .wrap_internal_err("generating charge ID")?; DBCharge { id: charge_id, user_id: charge.user_id, @@ -419,39 +440,47 @@ pub async fn refund_charge( tax_platform_accounting_time: None, } .upsert(&mut transaction) - .await?; + .await + .wrap_internal_err("generating charge ID")?; if body.0.unprovision.unwrap_or(false) && let Some(subscription_id) = charge.subscription_id { let open_charge = DBCharge::get_open_subscription(subscription_id, &**pool) - .await?; + .await + .wrap_internal_err("fetching charge from database")?; if let Some(mut open_charge) = open_charge { open_charge.status = ChargeStatus::Cancelled; open_charge.due = Utc::now(); - open_charge.upsert(&mut transaction).await?; + open_charge + .upsert(&mut transaction) + .await + .wrap_internal_err("cancelling subscription charge")?; } } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; if let Some(Err(error)) = anrok_result { if let anrok::AnrokError::Conflict(m) = &error && m.contains("transactionExpectedVersionMismatch") { - return Err(ApiError::InvalidInput( + return Err(ApiError::Request(eyre::eyre!( "This refund has been processed on Stripe's end, but not on the tax processor's end. The tax transaction has been modified externally since its creation. \ This is likely caused by a change in nexus for the customer's jurisdiction, which lead to a new tax amount paid by the seller being calculated on the transaction. \ Manual intervention is required to verify the tax transaction on the platform's end and update the refund's tax transaction record." .to_owned(), - )); + ))); } else { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "This refund has been processed on Stripe's end, but not on the tax processor's end. An unexpected error occurred, preventing the refund transaction from being processed \ on the tax platform's end. Error: {error}" - ))); + )))); } } } @@ -482,80 +511,86 @@ pub async fn reprocess_charge_tax( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let (id,) = info.into_inner(); if !user.role.is_admin() { - return Err(ApiError::CustomAuthentication( - "You do not have permission to reprocess a tax transaction!" - .to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to reprocess a tax transaction!", + ))); } - let mut txn = pool.begin().await?; + let mut txn = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let charge_refund = charge_item::DBCharge::get(id.into(), &mut txn) - .await? - .ok_or_else(|| ApiError::NotFound)?; + .await + .wrap_internal_err("fetching charge from database")? + .wrap_not_found_err_with(|| "resource not found")?; let Some(parent_charge_id) = charge_refund.parent_charge_id else { - return Err(ApiError::InvalidInput( - "This charge does not have a parent!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "This charge does not have a parent!", + ))); }; match charge_refund.tax_platform_id { Some(_) => { - return Err(ApiError::InvalidInput( - "Refund charge already has a tax transaction ID!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Refund charge already has a tax transaction ID!", + ))); } None => { let charge = charge_item::DBCharge::get(parent_charge_id, &mut txn) - .await? - .ok_or_else(|| ApiError::NotFound)?; + .await + .wrap_internal_err("fetching charge from database")? + .wrap_not_found_err_with(|| "resource not found")?; let payment_platform_id = charge .payment_platform_id - .ok_or_else(|| { - ApiError::Internal(eyre::eyre!( - "parent charge is missing a payment platform ID" - )) - })? + .wrap_internal_err_with( + || "parent charge is missing a payment platform ID", + )? .parse::() - .map_err(|_| { - ApiError::Internal(eyre::eyre!( - "parent charge has an invalid payment platform ID." - )) - })?; + .map_err(|err| eyre::eyre!(err)) + .wrap_internal_err( + "parent charge has an invalid payment platform ID.", + )?; let pi = stripe::PaymentIntent::retrieve( &stripe_client, &payment_platform_id, &["payment_method"], ) - .await?; + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; let Some(billing_address) = pi .payment_method .and_then(|x| x.into_object()) .and_then(|x| x.billing_details.address) else { - return Err(ApiError::InvalidInput( + return Err(ApiError::Request(eyre::eyre!( "Missing billing address for payment method.".to_owned(), - )); + ))); }; let tax_id = product_info_by_product_price_id(charge.price_id, &mut txn) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "Could not find product tax info for price ID!" - .to_owned(), - ) + .await + .wrap_api_err( + "executing `product_info_by_product_price_id`", + )? + .wrap_request_err_with(|| { + "could not find product tax info for price ID!" + .to_owned() })? .tax_identifier .tax_processor_id; @@ -569,22 +604,19 @@ pub async fn reprocess_charge_tax( .zip(charge.tax_transaction_version) .zip(charge.tax_platform_accounting_time) else { - return Err(ApiError::InvalidInput( + return Err(ApiError::Request(eyre::eyre!( "Charge is missing full tax information. Please wait for the original charge to be synchronized with the tax processor." .to_owned(), - )); + ))); }; let refund_id = - charge_refund.payment_platform_id.ok_or_else(|| { - ApiError::Internal(eyre::eyre!( - "Refund charge is missing a payment platform ID!" - )) - })?; + charge_refund.payment_platform_id.wrap_internal_err_with( + || "refund charge is missing a payment platform ID!", + )?; - let refund_id = - stripe::RefundId::from_str(&refund_id).map_err(|_| { - ApiError::Internal(eyre::eyre!("Invalid refund ID!")) - })?; + let refund_id = stripe::RefundId::from_str(&refund_id) + .map_err(|err| eyre::eyre!(err)) + .wrap_internal_err("invalid refund ID!")?; let anrok_txn_result = anrok_client .negate_or_create_partial_negation( @@ -621,14 +653,16 @@ pub async fn reprocess_charge_tax( .await; if let Err(error) = anrok_txn_result { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "There was an error processing the tax transaction: {error}. Please make sure the version has been incremented in case of an external modification." - ))); + )))); } } } - txn.commit().await?; + txn.commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().finish()) } @@ -678,7 +712,8 @@ pub async fn edit_subscription( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; #[derive(Clone, Copy, PartialEq, Eq)] @@ -699,21 +734,19 @@ pub async fn edit_subscription( new_product_price.product_id, &mut *txn, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "Could not link new product price to product.".to_owned(), - ) + .await + .wrap_internal_err("fetching product from database")? + .wrap_request_err_with(|| { + "could not link new product price to product.".to_owned() })?; let current_product = product_item::DBProduct::get( current_product_price.product_id, &mut *txn, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "Could not link current product price to product.".to_owned(), - ) + .await + .wrap_internal_err("fetching product from database")? + .wrap_request_err_with(|| { + "could not link current product price to product.".to_owned() })?; // Special case: for promoting a 'medal' subscription to 'pyro', compare the RAM. If pyro plan has: @@ -765,11 +798,9 @@ pub async fn edit_subscription( let current_amount = match ¤t_price.prices { Price::OneTime { price } => *price, Price::Recurring { intervals } => { - *intervals.get(&duration).ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid price for the user's duration" - .to_owned(), - ) + *intervals.get(&duration).wrap_request_err_with(|| { + "could not find a valid price for the user's duration" + .to_owned() })? } }; @@ -777,11 +808,9 @@ pub async fn edit_subscription( let amount = match &new_product_price.prices { Price::OneTime { price } => *price, Price::Recurring { intervals } => { - *intervals.get(&duration).ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid price for the user's duration" - .to_owned(), - ) + *intervals.get(&duration).wrap_request_err_with(|| { + "could not find a valid price for the user's duration" + .to_owned() })? } }; @@ -791,10 +820,8 @@ pub async fn edit_subscription( let proration = (Decimal::from(amount - current_amount) * complete) .floor() .to_i32() - .ok_or_else(|| { - ApiError::InvalidInput( - "Could not convert proration to i32".to_owned(), - ) + .wrap_request_err_with(|| { + "could not convert proration to i32".to_owned() })?; Ok(( @@ -815,35 +842,37 @@ pub async fn edit_subscription( let subscription = user_subscription_item::DBUserSubscription::get(id.into(), &**pool) - .await? - .ok_or_else(|| ApiError::NotFound)?; + .await + .wrap_internal_err("fetching user subscription from database")? + .wrap_not_found_err_with(|| "resource not found")?; if subscription.user_id != user.id.into() && !user.role.is_admin() { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; 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(), - ) + .await + .wrap_internal_err("fetching charge from database")? + .wrap_request_err_with(|| { + "could not find open charge for this subscription".to_string() })?; let current_price = product_item::DBProductPrice::get( subscription.price_id, &mut transaction, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "Could not find current product price".to_string(), - ) + .await + .wrap_internal_err("fetching product price from database")? + .wrap_request_err_with(|| { + "could not find current product price".to_string() })?; let maybe_intent_metadata = match edit_subscription.into_inner() { @@ -857,7 +886,10 @@ pub async fn edit_subscription( subscription.id, &mut transaction, ) - .await?; + .await + .wrap_internal_err( + "updating users subscriptions affiliations in database", + )?; open_charge.status = ChargeStatus::Cancelled; } else { // Forces another resubscription attempt @@ -881,7 +913,10 @@ pub async fn edit_subscription( subscription.id, &mut transaction, ) - .await?; + .await + .wrap_internal_err( + "executing `DBUsersSubscriptionsAffiliations::deactivate`", + )?; ChargeStatus::Cancelled } else { ChargeStatus::Open @@ -904,22 +939,21 @@ pub async fn edit_subscription( product_id.into(), &mut transaction, ) - .await? + .await + .wrap_internal_err("fetching product prices from database")? .into_iter() .find(|x| x.currency_code == current_price.currency_code) - .ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid price for your currency code!" - .to_owned(), - ) + .wrap_request_err_with(|| { + "could not find a valid price for your currency code!" + .to_owned() })?; // The price is the same! The request likely asked to edit the product to what it already is. if new_product_price.id == current_price.id { - return Err(ApiError::InvalidInput( + return Err(ApiError::Request(eyre::eyre!( "You cannot use the existing product when modifying a subscription! Modifications to only the billing interval aren't yet supported." .to_owned(), - )); + ))); } #[derive(Serialize)] @@ -931,9 +965,8 @@ pub async fn edit_subscription( let currency = stripe::Currency::from_str( ¤t_price.currency_code.to_lowercase(), ) - .map_err(|_| { - ApiError::InvalidInput("Invalid currency code".to_string()) - })?; + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("invalid currency code".to_string())?; // The next charge is an expiring charge, so we are promoting the subscription to a paid product. // Instead of doing a proration (since the product is likely free) we either: @@ -944,15 +977,16 @@ pub async fn edit_subscription( // // ..depending on the special cases defined in `promotion_payment_requirement`. if open_charge.status == ChargeStatus::Expiring { - let new_region = region.ok_or_else(|| ApiError::InvalidInput("You need to specify a region when promoting an expiring charge.".to_owned()))?; - let new_interval = interval.ok_or_else(|| ApiError::InvalidInput("You need to specify an interval when promoting an expiring charge.".to_owned()))?; + let new_region = region.wrap_request_err_with(|| "you need to specify a region when promoting an expiring charge.".to_owned())?; + let new_interval = interval.wrap_request_err_with(|| "you need to specify an interval when promoting an expiring charge.".to_owned())?; let req = promotion_payment_requirement( &mut transaction, ¤t_price, &new_product_price, ) - .await?; + .await + .wrap_api_err("executing `promotion_payment_requirement`")?; if dry { // Note: we aren't committing the transaction here and it will be aborted. @@ -966,10 +1000,8 @@ pub async fn edit_subscription( let payment_request_type = PaymentRequestType::from_stripe_id(payment_method) - .ok_or_else(|| { - ApiError::InvalidInput( - "Invalid payment method ID".to_owned(), - ) + .wrap_request_err_with(|| { + "invalid payment method ID".to_owned() })?; if req == PaymentRequirement::RequiresPayment { @@ -994,14 +1026,15 @@ pub async fn edit_subscription( attach_payment_metadata: None, }, ) - .await?; + .await + .wrap_api_err("creating subscription charge")?; Some(results) } else { /* open_charge.status = ChargeStatus::Open; open_charge.payment_platform = PaymentPlatform::Stripe; - open_charge.amount = new_product_price.prices.get_interval(new_interval).ok_or_else(|| ApiError::InvalidInput("Could not find a valid price for the user's duration".to_owned()))?; + open_charge.amount = new_product_price.prices.get_interval(new_interval).wrap_request_err_with(|| "could not find a valid price for the user's duration".to_owned())?; open_charge.currency_code = new_product_price.currency_code; open_charge.subscription_interval = Some(new_interval); open_charge.price_id = new_product_price.id; @@ -1019,7 +1052,8 @@ pub async fn edit_subscription( &subscription, ¤t_price, &new_product_price, - )?; + ) + .wrap_api_err("executing `proration_amount`")?; if dry { // Note: we aren't committing the transaction here and it will be aborted. @@ -1083,7 +1117,8 @@ pub async fn edit_subscription( attach_payment_metadata: None, }, ) - .await?; + .await + .wrap_api_err("creating subscription charge")?; Some(results) } @@ -1097,25 +1132,25 @@ pub async fn edit_subscription( interval, .. } if region.is_some() || interval.is_some() => { - return Err(ApiError::InvalidInput( + return Err(ApiError::Request(eyre::eyre!( "It is not currently possible to only modify the region or interval of a subscription".to_owned(), - )); + ))); } SubscriptionEdit { payment_method: None, .. } => { - return Err(ApiError::InvalidInput( + return Err(ApiError::Request(eyre::eyre!( "A known payment method is required at this point to calculate tax information".to_owned(), - )); + ))); } _ => { - return Err(ApiError::InvalidInput( + return Err(ApiError::Request(eyre::eyre!( "Unexpected combination of fields in subscription PATCH request. Please either only specify `cancelled`, or specify `product` \ alongside optionally specifying a `region` and `interval`. In some cases, you may be required to provide `region` and `interval`.".to_owned(), - )); + ))); } }; @@ -1125,8 +1160,14 @@ pub async fn edit_subscription( // At this point, if dry is true, we've already early-returned, except in // the `cancelled` branches. - open_charge.upsert(&mut transaction).await?; - transaction.commit().await?; + open_charge + .upsert(&mut transaction) + .await + .wrap_internal_err("committing database transaction")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; } if let Some(PaymentBootstrapResults { @@ -1169,7 +1210,8 @@ pub async fn user_customer( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let customer_id = get_or_create_customer( @@ -1180,9 +1222,14 @@ pub async fn user_customer( &pool, &redis, ) - .await?; + .await + .wrap_api_err("fetching or creating Stripe customer")?; let customer = - stripe::Customer::retrieve(&stripe_client, &customer_id, &[]).await?; + stripe::Customer::retrieve(&stripe_client, &customer_id, &[]) + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; Ok(HttpResponse::Ok().json(customer)) } @@ -1214,7 +1261,8 @@ pub async fn charges( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let charges = @@ -1223,17 +1271,17 @@ pub async fn charges( if user.role.is_admin() { user_id.into() } else { - return Err(ApiError::InvalidInput( - "You cannot see the subscriptions of other users!" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You cannot see the subscriptions of other users!", + ))); } } else { user.id.into() }, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching user charges from database")?; Ok(HttpResponse::Ok().json( charges @@ -1279,7 +1327,8 @@ pub async fn add_payment_method_flow( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let customer = get_or_create_customer( @@ -1290,7 +1339,8 @@ pub async fn add_payment_method_flow( &pool, &redis, ) - .await?; + .await + .wrap_api_err("fetching or creating Stripe customer")?; let intent = SetupIntent::create( &stripe_client, @@ -1305,7 +1355,7 @@ pub async fn add_payment_method_flow( ..Default::default() }, ) - .await?; + .await.wrap_failed_dependency_err("communicating with payment provider")?; Ok(HttpResponse::Ok().json(serde_json::json!({ "client_secret": intent.client_secret @@ -1339,13 +1389,14 @@ pub async fn edit_payment_method( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let (id,) = info.into_inner(); let Ok(payment_method_id) = PaymentMethodId::from_str(&id) else { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; let customer = get_or_create_customer( @@ -1356,14 +1407,16 @@ pub async fn edit_payment_method( &pool, &redis, ) - .await?; + .await + .wrap_api_err("fetching or creating Stripe customer")?; let payment_method = stripe::PaymentMethod::retrieve( &stripe_client, &payment_method_id, &[], ) - .await?; + .await + .wrap_failed_dependency_err("communicating with payment provider")?; if payment_method.customer.is_some_and(|x| x.id() == customer) || user.role.is_admin() @@ -1379,11 +1432,12 @@ pub async fn edit_payment_method( ..Default::default() }, ) - .await?; + .await + .wrap_failed_dependency_err("communicating with payment provider")?; Ok(HttpResponse::NoContent().finish()) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -1409,13 +1463,14 @@ pub async fn remove_payment_method( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let (id,) = info.into_inner(); let Ok(payment_method_id) = PaymentMethodId::from_str(&id) else { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; let customer = get_or_create_customer( @@ -1426,28 +1481,35 @@ pub async fn remove_payment_method( &pool, &redis, ) - .await?; + .await + .wrap_api_err("fetching or creating Stripe customer")?; let payment_method = stripe::PaymentMethod::retrieve( &stripe_client, &payment_method_id, &[], ) - .await?; + .await + .wrap_failed_dependency_err("communicating with payment provider")?; let user_subscriptions = user_subscription_item::DBUserSubscription::get_all_user( user.id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching user subscriptions from database")?; if user_subscriptions .iter() .any(|x| x.status != SubscriptionStatus::Unprovisioned) { let customer = - stripe::Customer::retrieve(&stripe_client, &customer, &[]).await?; + stripe::Customer::retrieve(&stripe_client, &customer, &[]) + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; if customer .invoice_settings @@ -1457,10 +1519,9 @@ pub async fn remove_payment_method( }) .unwrap_or(false) { - return Err(ApiError::InvalidInput( - "You may not remove the default payment method if you have active subscriptions!" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You may not remove the default payment method if you have active subscriptions!", + ))); } } @@ -1468,11 +1529,14 @@ pub async fn remove_payment_method( || user.role.is_admin() { stripe::PaymentMethod::detach(&stripe_client, &payment_method_id) - .await?; + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; Ok(HttpResponse::NoContent().finish()) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -1500,7 +1564,8 @@ pub async fn payment_methods( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; if let Some(customer_id) = user @@ -1516,7 +1581,8 @@ pub async fn payment_methods( ..Default::default() }, ) - .await?; + .await + .wrap_failed_dependency_err("communicating with payment provider")?; Ok(HttpResponse::Ok().json(methods.data)) } else { @@ -1560,16 +1626,15 @@ 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::Auth(eyre::eyre!("Invalid master key",))); } let servers = user_subscription_item::DBUserSubscription::get_all_servers( query.subscription_status, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching user subscriptions from database")?; let server_ids = servers .into_iter() @@ -1676,7 +1741,8 @@ pub async fn initiate_payment( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let payment_request = payment_request.into_inner(); @@ -1696,12 +1762,16 @@ pub async fn initiate_payment( &**pool, payment_request.charge, ) - .await?, + .await + .wrap_api_err( + "executing `AttachedCharge::from_charge_request_type`", + )?, currency: CurrencyMode::Infer, attach_payment_metadata: payment_request.metadata, }, ) - .await?; + .await + .wrap_api_err("executing `AttachedCharge::from_charge_request_type`")?; match results.new_payment_intent { Some(payment_intent) => { @@ -1785,7 +1855,8 @@ pub async fn stripe_webhook( crate::database::models::user_item::DBUser::get_id( user_id, pool, redis, ) - .await? + .await + .wrap_internal_err("fetching user from database")? else { break 'metadata; }; @@ -1826,13 +1897,17 @@ pub async fn stripe_webhook( crate::database::models::charge_item::DBCharge::get( charge_id, pool, ) - .await? + .await + .wrap_internal_err("fetching charge from database")? { let Some(price) = product_item::DBProductPrice::get( charge.price_id, pool, ) - .await? + .await + .wrap_internal_err( + "fetching product price from database", + )? else { break 'metadata; }; @@ -1841,7 +1916,8 @@ pub async fn stripe_webhook( price.product_id, pool, ) - .await? + .await + .wrap_internal_err("fetching product from database")? else { break 'metadata; }; @@ -1852,7 +1928,9 @@ pub async fn stripe_webhook( Some(payment_intent_id.to_string()); charge.tax_amount = tax_amount; charge.tax_platform_id = None; - charge.upsert(transaction).await?; + charge.upsert(transaction).await.wrap_internal_err( + "updating payment intent charge", + )?; if let Some(subscription_id) = charge.subscription_id { let maybe_subscription = @@ -1860,7 +1938,7 @@ pub async fn stripe_webhook( subscription_id, pool, ) - .await?; + .await.wrap_internal_err("fetching user subscription from database")?; let Some(mut subscription) = maybe_subscription else { @@ -1880,14 +1958,18 @@ pub async fn stripe_webhook( subscription.price_id = charge.price_id; } ChargeType::Refund => { - return Err(ApiError::InvalidInput( - "Invalid charge type: Refund" - .to_string(), + return Err(ApiError::Request( + eyre::eyre!( + "Invalid charge type: Refund", + ), )); } } - subscription.upsert(transaction).await?; + subscription + .upsert(transaction) + .await + .wrap_internal_err("upserting subscription")?; ( charge, @@ -1914,7 +1996,10 @@ pub async fn stripe_webhook( let Some(price) = product_item::DBProductPrice::get(price_id, pool) - .await? + .await + .wrap_internal_err( + "fetching product price from database", + )? else { break 'metadata; }; @@ -1923,7 +2008,8 @@ pub async fn stripe_webhook( price.product_id, pool, ) - .await?; + .await + .wrap_internal_err("fetching product from database")?; let Some(product) = maybe_product else { break 'metadata; @@ -1949,7 +2035,7 @@ pub async fn stripe_webhook( break 'metadata; }; - let subscription = if let Some(mut subscription) = user_subscription_item::DBUserSubscription::get(subscription_id, pool).await? { + let subscription = if let Some(mut subscription) = user_subscription_item::DBUserSubscription::get(subscription_id, pool).await.wrap_internal_err("fetching user subscription from database")? { subscription.status = SubscriptionStatus::Unprovisioned; subscription.price_id = price_id; subscription.interval = interval; @@ -1970,7 +2056,10 @@ pub async fn stripe_webhook( if charge_status != ChargeStatus::Failed { subscription .upsert(&mut *transaction) - .await?; + .await + .wrap_internal_err( + "upserting subscription", + )?; } Some(subscription) @@ -2011,7 +2100,10 @@ pub async fn stripe_webhook( }; if charge_status != ChargeStatus::Failed { - charge.upsert(&mut *transaction).await?; + charge + .upsert(&mut *transaction) + .await + .wrap_internal_err("upserting charge")?; } (charge, price, product, subscription, new_region) @@ -2029,9 +2121,9 @@ pub async fn stripe_webhook( }); } - Err(ApiError::InvalidInput( - "Webhook missing required webhook metadata!".to_string(), - )) + Err(ApiError::Request(eyre::eyre!( + "Webhook missing required webhook metadata!", + ))) } match event.type_ { @@ -2039,7 +2131,10 @@ pub async fn stripe_webhook( if let EventObject::PaymentIntent(payment_intent) = event.data.object { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let mut metadata = get_payment_intent_metadata( payment_intent.id, @@ -2051,7 +2146,8 @@ pub async fn stripe_webhook( ChargeStatus::Succeeded, &mut transaction, ) - .await?; + .await + .wrap_api_err("fetching payment intent metadata")?; if let Some(latest_charge) = payment_intent.latest_charge { let charge = stripe::Charge::retrieve( @@ -2059,7 +2155,10 @@ pub async fn stripe_webhook( &latest_charge.id(), &["balance_transaction"], ) - .await?; + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; if let Some(balance_transaction) = charge .balance_transaction @@ -2070,7 +2169,8 @@ pub async fn stripe_webhook( metadata .charge_item .upsert(&mut transaction) - .await?; + .await + .wrap_internal_err("upserting charge")?; } } @@ -2098,7 +2198,8 @@ pub async fn stripe_webhook( as crate::database::models::ids::DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `get_payment_intent_metadata`")?; } ProductMetadata::Pyro { ram, @@ -2124,11 +2225,11 @@ pub async fn stripe_webhook( let region = metadata.new_region.clone(); if region.is_none() { - return Err(ApiError::InvalidInput( + return Err(ApiError::Request(eyre::eyre!( "We attempted to promote a subscription with type=medal, which requires specifying \ a new region to move the server to. However, no new region was present in the payment \ intent metadata.".to_owned() - )); + ))); } region @@ -2162,8 +2263,8 @@ pub async fn stripe_webhook( )) .header("X-Master-Key", &ENV.PYRO_API_KEY) .send() - .await? - .error_for_status()?; + .await.wrap_internal_err("sending HTTP request")? + .error_for_status().wrap_internal_err("sending HTTP request")?; client .post(format!( @@ -2177,8 +2278,8 @@ pub async fn stripe_webhook( ) .json(&body) .send() - .await? - .error_for_status()?; + .await.wrap_internal_err("deserializing HTTP response")? + .error_for_status().wrap_internal_err("deserializing HTTP response")?; // As the subscription has been promoted, this is now a Pyro subscription. // Ensure the metadata is properly updated. @@ -2206,7 +2307,7 @@ pub async fn stripe_webhook( None, &**pool, &redis, - ).await?; + ).await.wrap_internal_err("fetching minecraft versions from database")?; ( None, @@ -2257,10 +2358,10 @@ pub async fn stripe_webhook( }) })) .send() - .await? - .error_for_status()? + .await.wrap_internal_err("sending HTTP request")? + .error_for_status().wrap_internal_err("sending HTTP request")? .json::() - .await?; + .await.wrap_internal_err("deserializing HTTP response")?; if let Some(ref mut subscription) = metadata.user_subscription_item @@ -2283,17 +2384,14 @@ pub async fn stripe_webhook( subscription.id, &mut transaction, ) - .await?; + .await + .wrap_internal_err("fetching charge from database")?; let new_price = match metadata.product_price_item.prices { Price::OneTime { price } => price, Price::Recurring { intervals } => { - *intervals.get(&subscription.interval).ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid price for the user's country" - .to_string(), - ) - })? + *intervals.get(&subscription.interval).wrap_request_err_with(|| "could not find a valid price for the user's country" + .to_string())? } }; @@ -2333,12 +2431,19 @@ pub async fn stripe_webhook( metadata.product_price_item.id; charge.amount = new_price as i64; } - charge.upsert(&mut transaction).await?; + charge + .upsert(&mut transaction) + .await + .wrap_internal_err("upserting charge")?; } else if metadata.charge_item.status != ChargeStatus::Cancelled { let charge_id = - generate_charge_id(&mut transaction).await?; + generate_charge_id(&mut transaction) + .await + .wrap_internal_err( + "generating charge ID", + )?; DBCharge { id: charge_id, user_id: metadata.user_item.id, @@ -2375,7 +2480,10 @@ pub async fn stripe_webhook( tax_platform_accounting_time: None, } .upsert(&mut transaction) - .await?; + .await + .wrap_internal_err( + "upserting subscription charge", + )?; if let Some(affiliate_code) = metadata .payment_metadata @@ -2390,27 +2498,42 @@ pub async fn stripe_webhook( deactivated_at: None, } .insert(&mut transaction) - .await?; + .await + .wrap_internal_err( + "inserting subscription affiliation into database", + )?; } }; subscription.status = SubscriptionStatus::Provisioned; - subscription.upsert(&mut transaction).await?; + subscription + .upsert(&mut transaction) + .await + .wrap_internal_err( + "updating affiliate code id in database", + )?; } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; crate::database::models::user_item::DBUser::clear_caches( &[(metadata.user_item.id, None)], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; } } EventType::PaymentIntentProcessing => { if let EventObject::PaymentIntent(payment_intent) = event.data.object { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; get_payment_intent_metadata( payment_intent.id, payment_intent.amount, @@ -2421,15 +2544,22 @@ pub async fn stripe_webhook( ChargeStatus::Processing, &mut transaction, ) - .await?; - transaction.commit().await?; + .await + .wrap_api_err("fetching payment intent metadata")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; } } EventType::PaymentIntentPaymentFailed => { if let EventObject::PaymentIntent(payment_intent) = event.data.object { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let metadata = get_payment_intent_metadata( payment_intent.id, @@ -2441,7 +2571,8 @@ pub async fn stripe_webhook( ChargeStatus::Failed, &mut transaction, ) - .await?; + .await + .wrap_api_err("fetching payment intent metadata")?; if metadata.user_item.email.is_some() { let money = rusty_money::Money::from_minor( @@ -2474,10 +2605,16 @@ pub async fn stripe_webhook( }, } .insert(metadata.user_item.id, &mut transaction, &redis) - .await?; + .await + .wrap_internal_err( + "inserting payment failure notification", + )?; } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; } } EventType::PaymentMethodAttached => { @@ -2491,7 +2628,10 @@ pub async fn stripe_webhook( &customer_id, &[], ) - .await?; + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; if customer .invoice_settings @@ -2512,16 +2652,19 @@ pub async fn stripe_webhook( ..Default::default() }, ) - .await?; + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; } } } _ => {} } } else { - return Err(ApiError::InvalidInput( - "Webhook signature validation failed!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Webhook signature validation failed!", + ))); } Ok(HttpResponse::Ok().finish()) @@ -2545,7 +2688,8 @@ async fn apply_credit_many( &subs_ids, &mut *transaction, ) - .await?; + .await + .wrap_internal_err("fetching user subscriptions from database")?; let provisioned_count = subs .iter() @@ -2573,18 +2717,22 @@ async fn apply_credit_many( subscription.id, &mut *transaction, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Could not find open charge for subscription {}", + .await + .wrap_internal_err("fetching charge from database")? + .wrap_request_err_with(|| { + format!( + "could not find open charge for subscription `{}`", to_base62(subscription.id.0 as u64) - )) + ) })?; let previous_due = open_charge.due; open_charge.due = previous_due + Duration::days(days as i64); let next_due = open_charge.due; - open_charge.upsert(&mut *transaction).await?; + open_charge + .upsert(&mut *transaction) + .await + .wrap_internal_err("updating next due in database")?; credit_sub_ids.push(subscription.id); credit_user_ids.push(subscription.user_id); @@ -2604,7 +2752,10 @@ async fn apply_credit_many( }, } .insert(subscription.user_id, &mut *transaction, redis) - .await?; + .await + .wrap_internal_err( + "inserting database records for `apply_credit_many`", + )?; } } @@ -2618,7 +2769,8 @@ async fn apply_credit_many( &credit_next_dues, ) .await - .map_err(|e| ApiError::Internal(eyre::eyre!(e)))?; + .map_err(|err| eyre::eyre!(err)) + .wrap_internal_err("inserting subscription credits into database")?; Ok(()) } @@ -2668,13 +2820,14 @@ pub async fn credit( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; if !user.role.is_admin() { - return Err(ApiError::CustomAuthentication( - "You do not have permission to credit subscriptions!".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to credit subscriptions!", + ))); } let CreditRequest { @@ -2685,18 +2838,21 @@ pub async fn credit( } = body.into_inner(); if days <= 0 { - return Err(ApiError::InvalidInput( - "Days must be greater than zero".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Days must be greater than zero", + ))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; match target { CreditTarget::Subscriptions { subscription_ids } => { if subscription_ids.is_empty() { - return Err(ApiError::InvalidInput( - "You must specify at least one subscription id".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You must specify at least one subscription id", + ))); } apply_credit_many( &mut transaction, @@ -2707,18 +2863,23 @@ pub async fn credit( send_email, message, ) - .await?; + .await + .wrap_api_err("crediting user subscriptions")?; } CreditTarget::Nodes { nodes } => { if nodes.is_empty() { - return Err(ApiError::InvalidInput( - "You must specify at least one node hostname".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You must specify at least one node hostname", + ))); } let mut server_ids: Vec = Vec::new(); for hostname in nodes { - let ids = - archon_client.get_servers_by_hostname(&hostname).await?; + let ids = archon_client + .get_servers_by_hostname(&hostname) + .await + .wrap_internal_err( + "fetching servers by hostname from Archon", + )?; server_ids.extend(ids.into_iter().map(|id| id.to_string())); } server_ids.dedup(); @@ -2726,11 +2887,11 @@ pub async fn credit( &server_ids, &mut transaction, ) - .await?; + .await.wrap_internal_err("fetching user subscriptions from database")?; if subs.is_empty() { - return Err(ApiError::InvalidInput( - "No subscriptions found for provided nodes".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "No subscriptions found for provided nodes", + ))); } apply_credit_many( &mut transaction, @@ -2741,20 +2902,23 @@ pub async fn credit( send_email, message, ) - .await?; + .await + .wrap_api_err("crediting server subscriptions")?; } CreditTarget::Region { region } => { - let servers = - archon_client.get_active_servers_by_region(®ion).await?; + let servers = archon_client + .get_active_servers_by_region(®ion) + .await + .wrap_internal_err("fetching servers from database")?; let subs = user_subscription_item::DBUserSubscription::get_many_by_server_ids( &servers.into_iter().map(|id| id.to_string()).collect::>(), &mut transaction, ) - .await?; + .await.wrap_internal_err("fetching user subscriptions from database")?; if subs.is_empty() { - return Err(ApiError::InvalidInput( - "No subscriptions found for provided region".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "No subscriptions found for provided region", + ))); } apply_credit_many( &mut transaction, @@ -2765,11 +2929,15 @@ pub async fn credit( send_email, message, ) - .await?; + .await + .wrap_api_err("crediting region subscriptions")?; } } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().finish()) } diff --git a/apps/labrinth/src/routes/internal/billing/payments.rs b/apps/labrinth/src/routes/internal/billing/payments.rs index 2e0f445243..e73bd778b5 100644 --- a/apps/labrinth/src/routes/internal/billing/payments.rs +++ b/apps/labrinth/src/routes/internal/billing/payments.rs @@ -8,6 +8,7 @@ use crate::models::v3::billing::SubscriptionStatus; use crate::models::v3::users::User; use crate::routes::ApiError; use crate::util::anrok; +use crate::util::error::{ApiContext as _, Context as _}; use xredis::RedisPool; use crate::database::PgPool; @@ -97,13 +98,12 @@ impl AttachedCharge { ) -> Result { Ok(match charge_request_type { ChargeRequestType::Existing { id } => AttachedCharge::UseExisting { - charge: DBCharge::get(id.into(), exec).await?.ok_or_else( - || { - ApiError::InvalidInput( - "Could not find charge".to_string(), - ) - }, - )?, + charge: DBCharge::get(id.into(), exec) + .await + .wrap_internal_err("fetching charge from database")? + .wrap_request_err_with(|| { + "could not find charge".to_string() + })?, }, ChargeRequestType::New { product_id, @@ -212,7 +212,8 @@ pub async fn create_or_update_payment_intent( pg, redis, ) - .await?; + .await + .wrap_api_err("fetching or creating Stripe customer")?; let mut intent_uses_confirmation_token = false; @@ -220,15 +221,15 @@ pub async fn create_or_update_payment_intent( PaymentSession::Interactive { payment_request_type: PaymentRequestType::PaymentMethod { id }, } => { - let payment_method_id = - PaymentMethodId::from_str(id).map_err(|_| { - ApiError::InvalidInput( - "Invalid payment method id".to_string(), - ) - })?; + let payment_method_id = PaymentMethodId::from_str(id) + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("invalid payment method id".to_string())?; PaymentMethod::retrieve(stripe_client, &payment_method_id, &[]) - .await? + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )? } PaymentSession::Interactive { payment_request_type: @@ -243,7 +244,10 @@ pub async fn create_or_update_payment_intent( let mut confirmation: serde_json::Value = stripe_client .get(&format!("confirmation_tokens/{token}")) - .await?; + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; // We patch the JSONs to support the PaymentMethod struct let p: json_patch::Patch = serde_json::from_value(serde_json::json!([ @@ -254,19 +258,20 @@ pub async fn create_or_update_payment_intent( json_patch::patch(&mut confirmation, &p).unwrap(); let confirmation: ConfirmationToken = - serde_json::from_value(confirmation)?; + serde_json::from_value(confirmation) + .wrap_request_err("deserializing JSON data")?; - confirmation.payment_method_preview.ok_or_else(|| { - ApiError::InvalidInput( - "Confirmation token is missing payment method!".to_string(), - ) - })? + confirmation + .payment_method_preview + .wrap_request_err_with(|| { + "confirmation token is missing payment method!".to_string() + })? } PaymentSession::AutomatedRenewal => { if attached_charge.as_charge().is_none() { - return Err(ApiError::InvalidInput( - "Missing attached charge for automated renewal".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Missing attached charge for automated renewal", + ))); } let customer = stripe::Customer::retrieve( @@ -274,17 +279,18 @@ pub async fn create_or_update_payment_intent( &customer_id, &["invoice_settings.default_payment_method"], ) - .await?; + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; customer .invoice_settings .and_then(|x| { x.default_payment_method.and_then(|x| x.into_object()) }) - .ok_or_else(|| { - ApiError::InvalidInput( - "Customer has no default payment method!".to_string(), - ) + .wrap_request_err_with(|| { + "customer has no default payment method!".to_string() })? } }; @@ -301,9 +307,8 @@ pub async fn create_or_update_payment_intent( CurrencyMode::Infer => infer_currency_code(user_country) .to_lowercase() .parse::() - .map_err(|_| { - ApiError::InvalidInput("Invalid currency code".to_string()) - })?, + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("invalid currency code".to_string())?, }; let charge_data = match attached_charge { @@ -335,7 +340,8 @@ pub async fn create_or_update_payment_intent( Some(next_interval), inferred_stripe_currency, ) - .await?; + .await + .wrap_api_err("deriving proration charge data")?; charge_data.amount = amount; charge_data.charge_type = ChargeType::Proration; @@ -346,29 +352,27 @@ pub async fn create_or_update_payment_intent( interval, current_subscription: _, new_region: _, - } => { - derive_charge_data_from_product_selector( - pg, - user.id, - product_id, - Some(interval), - inferred_stripe_currency, - ) - .await? - } + } => derive_charge_data_from_product_selector( + pg, + user.id, + product_id, + Some(interval), + inferred_stripe_currency, + ) + .await + .wrap_api_err("deriving promotion charge data")?, AttachedCharge::BaseUpon { product_id, interval, - } => { - derive_charge_data_from_product_selector( - pg, - user.id, - product_id, - interval, - inferred_stripe_currency, - ) - .await? - } + } => derive_charge_data_from_product_selector( + pg, + user.id, + product_id, + interval, + inferred_stripe_currency, + ) + .await + .wrap_api_err("deriving base charge data")?, }; // Create an ephemeral transaction to calculate the tax amount if needed @@ -388,23 +392,18 @@ pub async fn create_or_update_payment_intent( charge_data.price_id.into(), pg, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "Missing product tax identifier for charge to continue" - .to_owned(), - ) + .await + .wrap_internal_err("fetching product tax identifier")? + .wrap_request_err_with(|| { + "missing product tax identifier for charge to continue" + .to_owned() })?; - let address = - payment_method.billing_details.address.clone().ok_or_else( - || { - ApiError::InvalidInput( - "Missing billing details from payment method to continue" - .to_owned(), - ) - }, - )?; + let address = payment_method + .billing_details + .address + .clone() + .wrap_request_err("finding billing details on payment method")?; let ephemeral_invoice = anrok_client .create_ephemeral_txn(&anrok::TransactionFields { @@ -419,7 +418,8 @@ pub async fn create_or_update_payment_intent( customer_id: None, customer_name: None, }) - .await?; + .await + .wrap_internal_err("inserting database records for `create_or_update_payment_intent`")?; ephemeral_invoice.tax_amount_to_collect }; @@ -436,7 +436,8 @@ pub async fn create_or_update_payment_intent( if let Some(payment_metadata) = attach_payment_metadata { metadata.insert( MODRINTH_PAYMENT_METADATA.to_owned(), - serde_json::to_string(&payment_metadata)?, + serde_json::to_string(&payment_metadata) + .wrap_request_err("inserting payment metadata into database")?, ); } @@ -459,8 +460,13 @@ pub async fn create_or_update_payment_intent( current_subscription, } = attached_charge { - let mut transaction = pg.begin().await?; - let charge_id = generate_charge_id(&mut transaction).await?; + let mut transaction = pg + .begin() + .await + .wrap_internal_err("starting database transaction")?; + let charge_id = generate_charge_id(&mut transaction) + .await + .wrap_internal_err("generating charge ID")?; metadata.insert( MODRINTH_CHARGE_ID.to_owned(), @@ -486,8 +492,13 @@ pub async fn create_or_update_payment_intent( new_region, } = attached_charge { - let mut transaction = pg.begin().await?; - let charge_id = generate_charge_id(&mut transaction).await?; + let mut transaction = pg + .begin() + .await + .wrap_internal_err("starting database transaction")?; + let charge_id = generate_charge_id(&mut transaction) + .await + .wrap_internal_err("generating charge ID")?; metadata.insert( MODRINTH_CHARGE_ID.to_owned(), @@ -508,10 +519,16 @@ pub async fn create_or_update_payment_intent( ); metadata.insert(MODRINTH_NEW_REGION.to_owned(), new_region); } else { - let mut transaction = pg.begin().await?; - let charge_id = generate_charge_id(&mut transaction).await?; - let subscription_id = - generate_user_subscription_id(&mut transaction).await?; + let mut transaction = pg + .begin() + .await + .wrap_internal_err("starting database transaction")?; + let charge_id = generate_charge_id(&mut transaction) + .await + .wrap_internal_err("generating charge ID")?; + let subscription_id = generate_user_subscription_id(&mut transaction) + .await + .wrap_internal_err("generating charge ID")?; metadata.insert( MODRINTH_CHARGE_ID.to_owned(), @@ -538,7 +555,11 @@ pub async fn create_or_update_payment_intent( if let Some(payment_intent_id) = existing_payment_intent { let mut update_payment_intent = stripe::UpdatePaymentIntent { amount: Some(charge_data.amount + tax_amount), - currency: Some(charge_data.stripe_currency_code()?), + currency: Some( + charge_data + .stripe_currency_code() + .wrap_api_err("converting charge currency for Stripe")?, + ), customer: Some(customer_id), metadata: Some(metadata), ..Default::default() @@ -560,7 +581,10 @@ pub async fn create_or_update_payment_intent( &payment_intent_id, update_payment_intent, ) - .await?; + .await + .wrap_failed_dependency_err( + "updating database records for `create_or_update_payment_intent`", + )?; Ok(PaymentBootstrapResults { new_payment_intent: None, @@ -572,7 +596,9 @@ pub async fn create_or_update_payment_intent( } else { let mut intent = CreatePaymentIntent::new( charge_data.amount + tax_amount, - charge_data.stripe_currency_code()?, + charge_data + .stripe_currency_code() + .wrap_api_err("converting charge currency for Stripe")?, ); intent.customer = Some(customer_id); @@ -585,7 +611,11 @@ pub async fn create_or_update_payment_intent( payment_session.set_payment_intent_session_options(&mut intent); let payment_intent = - stripe::PaymentIntent::create(stripe_client, intent).await?; + stripe::PaymentIntent::create(stripe_client, intent) + .await + .wrap_failed_dependency_err( + "communicating with payment provider", + )?; Ok(PaymentBootstrapResults { new_payment_intent: Some(payment_intent), @@ -621,7 +651,8 @@ pub async fn get_or_create_customer( ..Default::default() }, ) - .await?; + .await + .wrap_failed_dependency_err("communicating with payment provider")?; sqlx::query!( " @@ -633,13 +664,15 @@ pub async fn get_or_create_customer( user_id.0 as i64 ) .execute(pool) - .await?; + .await + .wrap_internal_err("querying database for `get_or_create_customer`")?; crate::database::models::user_item::DBUser::clear_caches( &[(user_id.into(), None)], redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(customer.id) } @@ -720,9 +753,9 @@ impl ChargeData { self.currency_code .to_lowercase() .parse::() - .map_err(|_| ApiError::InvalidInput( + .map_err(|_| ApiError::Request(eyre::eyre!( format!("Invalid currency code '{}': could not convert to Stripe currency", &self.currency_code) - )) + ))) } } @@ -736,18 +769,18 @@ async fn derive_charge_data_from_product_selector( let recommended_currency_code = stripe_currency.to_string().to_uppercase(); let product = product_item::DBProduct::get(product_id.into(), pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "Specified product could not be found!".to_string(), - ) + .await + .wrap_internal_err("fetching product from database")? + .wrap_request_err_with(|| { + "specified product could not be found!".to_string() })?; let mut product_prices = product_item::DBProductPrice::get_all_public_product_prices( product.id, pool, ) - .await?; + .await + .wrap_internal_err("fetching product prices from database")?; let price_item = if let Some(pos) = product_prices .iter() @@ -759,26 +792,22 @@ async fn derive_charge_data_from_product_selector( { product_prices.remove(pos) } else { - return Err(ApiError::InvalidInput( - "Could not find a valid price for the user's country".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Could not find a valid price for the user's country", + ))); }; let price = match price_item.prices { Price::OneTime { price } => price, Price::Recurring { ref intervals } => { - let interval = interval.ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid price for the user's country" - .to_string(), - ) + let interval = interval.wrap_request_err_with(|| { + "could not find a valid price for the user's country" + .to_string() })?; - *intervals.get(&interval).ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid price for the user's country" - .to_string(), - ) + *intervals.get(&interval).wrap_request_err_with(|| { + "could not find a valid price for the user's country" + .to_string() })? } }; @@ -791,7 +820,8 @@ async fn derive_charge_data_from_product_selector( user_id.into(), pool, ) - .await?; + .await + .wrap_internal_err("fetching user subscriptions from database")?; let user_products = product_item::DBProductPrice::get_many( &user_subscriptions @@ -801,15 +831,16 @@ async fn derive_charge_data_from_product_selector( .collect::>(), pool, ) - .await?; + .await + .wrap_internal_err("fetching product prices from database")?; if user_products .into_iter() .any(|x| x.product_id == product.id) { - return Err(ApiError::InvalidInput( - "You are already subscribed to this product!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You are already subscribed to this product!", + ))); } } diff --git a/apps/labrinth/src/routes/internal/billing/update_subscriptions.rs b/apps/labrinth/src/routes/internal/billing/update_subscriptions.rs index cc003d4d22..592d11a00a 100644 --- a/apps/labrinth/src/routes/internal/billing/update_subscriptions.rs +++ b/apps/labrinth/src/routes/internal/billing/update_subscriptions.rs @@ -104,7 +104,10 @@ pub async fn update_many( *region = Some(new_region); } - subscription.upsert(&mut txn).await?; + subscription + .upsert(&mut txn) + .await + .wrap_internal_err("updating uuid in database")?; } txn.commit() diff --git a/apps/labrinth/src/routes/internal/blocked_users.rs b/apps/labrinth/src/routes/internal/blocked_users.rs index 67b305970d..acffff0475 100644 --- a/apps/labrinth/src/routes/internal/blocked_users.rs +++ b/apps/labrinth/src/routes/internal/blocked_users.rs @@ -2,6 +2,7 @@ use crate::database::PgPool; use crate::database::models::DBUserId; use crate::database::models::blocked_user_item::DBBlockedUser; use crate::routes::ApiError; +use crate::util::error::Context as _; use crate::util::guards::admin_key_guard; use actix_web::{get, web}; use ariadne::ids::base62_impl::parse_base62; @@ -25,17 +26,20 @@ pub async fn block_status( ) -> Result, ApiError> { let (user_id, target_id) = info.into_inner(); - let user_id = - DBUserId(parse_base62(&user_id).map_err(|_| { - ApiError::InvalidInput("invalid user_id".to_string()) - })? as i64); - let target_id = - DBUserId(parse_base62(&target_id).map_err(|_| { - ApiError::InvalidInput("invalid target_id".to_string()) - })? as i64); + let user_id = DBUserId( + parse_base62(&user_id) + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("invalid user_id".to_string())? as i64, + ); + let target_id = DBUserId( + parse_base62(&target_id) + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("invalid target_id".to_string())? as i64, + ); - let blocked = - DBBlockedUser::is_blocked(user_id, target_id, &**pool).await?; + let blocked = DBBlockedUser::is_blocked(user_id, target_id, &**pool) + .await + .wrap_internal_err("checking whether user is blocked")?; Ok(web::Json(BlockStatus { blocked })) } diff --git a/apps/labrinth/src/routes/internal/campaign.rs b/apps/labrinth/src/routes/internal/campaign.rs index b440558cc5..cff8486d03 100644 --- a/apps/labrinth/src/routes/internal/campaign.rs +++ b/apps/labrinth/src/routes/internal/campaign.rs @@ -1,3 +1,4 @@ +use crate::util::error::ApiContext as _; use actix_web::{HttpRequest, get, post, web}; use base64::Engine; use chrono::{DateTime, Duration, Utc}; @@ -158,7 +159,8 @@ pub async fn tiltify_webhook( payouts_queue: web::Data, body: String, ) -> Result<(), ApiError> { - verify_tiltify_webhook_signature(&req, &body)?; + verify_tiltify_webhook_signature(&req, &body) + .wrap_api_err("executing `verify_tiltify_webhook_signature`")?; let raw_payload = serde_json::from_str::(&body) .wrap_internal_err_with(|| eyre!("invalid Tiltify webhook JSON"))?; @@ -181,7 +183,9 @@ pub async fn tiltify_webhook( .begin() .await .wrap_internal_err("beginning transaction")?; - let id = generate_campaign_donation_id(&mut transaction).await?; + let id = generate_campaign_donation_id(&mut transaction) + .await + .wrap_internal_err("generating campaign donation ID")?; let mut donation = CampaignDonation { id, @@ -377,7 +381,8 @@ pub async fn pride_26( total_donations_usd: response.data.total_amount_raised.value, target_usd: response.data.goal.value, num_donators: num_donators(&http, &access_token, campaign_id) - .await?, + .await + .wrap_api_err("executing `num_donators`")?, cached_at: Utc::now(), }; diff --git a/apps/labrinth/src/routes/internal/delphi/mod.rs b/apps/labrinth/src/routes/internal/delphi/mod.rs index 1a5d9e55fb..ef265171d2 100644 --- a/apps/labrinth/src/routes/internal/delphi/mod.rs +++ b/apps/labrinth/src/routes/internal/delphi/mod.rs @@ -1,3 +1,4 @@ +use crate::util::error::ApiContext as _; use std::{collections::HashMap, fmt::Write, time::Instant}; use xredis::RedisPool; @@ -156,7 +157,7 @@ pub async fn ingest_report( let report = serde_json::from_value::(report.clone()) .wrap_internal_err_with(|| { eyre!( - "Delphi sent a response which does not match our schema\n\n{}", + "received a Delphi response which does not match our schema\n\n{}", serde_json::to_string_pretty(&report).unwrap() ) })?; @@ -251,7 +252,8 @@ async fn ingest_report_deserialized( tech_review_sync::TechReviewExitReason::Resolved, &mut transaction, ) - .await?; + .await + .wrap_api_err("synchronizing project technical-review state")?; transaction .commit() @@ -278,7 +280,7 @@ pub async fn run( run_parameters.file_id.0 as i64 ) .fetch_one(exec) - .await?; + .await.wrap_internal_err("fetching file from database")?; tracing::debug!( "Running Delphi for project {}, version {}, file {}", @@ -297,7 +299,7 @@ pub async fn run( .send() .await .and_then(|res| res.error_for_status()) - .map_err(ApiError::delphi)?; + .wrap_internal_err("requesting a Delphi scan")?; Ok(HttpResponse::NoContent().finish()) } @@ -325,7 +327,8 @@ pub async fn _run( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; run(&**pool, run_parameters.into_inner(), &http).await } @@ -350,12 +353,14 @@ pub async fn version( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; Ok(HttpResponse::Ok().json( sqlx::query_scalar!("SELECT MAX(delphi_version) FROM delphi_reports") .fetch_one(&**pool) - .await?, + .await + .wrap_internal_err("deserializing HTTP response")?, )) } @@ -380,7 +385,8 @@ pub async fn issue_type_schema( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("deserializing HTTP response")?; // This route is expected to be called often by the frontend, and Delphi is not necessarily // built to scale beyond malware analysis, so cache the result of its quasi-constant-valued @@ -401,10 +407,10 @@ pub async fn issue_type_schema( .send() .await .and_then(|res| res.error_for_status()) - .map_err(ApiError::delphi)? + .wrap_internal_err("fetching the Delphi schema")? .json::>() .await - .map_err(ApiError::delphi)?, + .wrap_internal_err("deserializing the Delphi schema")?, Instant::now(), )) .0, diff --git a/apps/labrinth/src/routes/internal/delphi/tech_review_sync.rs b/apps/labrinth/src/routes/internal/delphi/tech_review_sync.rs index e67c9e517e..ca80c6e9e9 100644 --- a/apps/labrinth/src/routes/internal/delphi/tech_review_sync.rs +++ b/apps/labrinth/src/routes/internal/delphi/tech_review_sync.rs @@ -38,6 +38,7 @@ //! to act as a single chokepoint which (correctly) syncs all the state, instead //! of having each mutation run its own ad-hoc update logic. +use crate::util::error::ApiContext as _; use itertools::Itertools; use crate::{ @@ -170,7 +171,9 @@ pub async fn sync_project_tech_review_state( last_tech_review_message_type: row.last_tech_review_message_type, }; - sync_one_project_tech_review_state(state, exit_reason, txn).await?; + sync_one_project_tech_review_state(state, exit_reason, txn) + .await + .wrap_api_err("executing `sync_one_project_tech_review_state`")?; } Ok(()) @@ -245,7 +248,8 @@ pub async fn sync_deleted_project_tech_review_exit( && should_send_exit(row.last_tech_review_message_type.as_deref()) { insert_exit_message(thread_id, TechReviewExitReason::FileDeleted, txn) - .await?; + .await + .wrap_api_err("executing `insert_exit_message`")?; } Ok(()) @@ -275,7 +279,9 @@ async fn sync_one_project_tech_review_state( // an append-only project tech review event table where the latest // enter/exit event is the current state. Until then, this dummy // issue detail acts as the pending queue blocker. - ensure_dummy_issue_detail(report_id, txn).await?; + ensure_dummy_issue_detail(report_id, txn) + .await + .wrap_api_err("validating dummy issue detail")?; } if let Some(thread_id) = state.thread_id @@ -301,7 +307,9 @@ async fn sync_one_project_tech_review_state( == Some(MessageBody::TechReviewEntered.as_ref()) { if let Some(report_id) = state.report_id { - ensure_dummy_issue_detail(report_id, txn).await?; + ensure_dummy_issue_detail(report_id, txn) + .await + .wrap_api_err("validating dummy issue detail")?; } return Ok(()); @@ -310,7 +318,9 @@ async fn sync_one_project_tech_review_state( if let Some(thread_id) = state.thread_id && should_send_exit(state.last_tech_review_message_type.as_deref()) { - insert_exit_message(thread_id, exit_reason, txn).await?; + insert_exit_message(thread_id, exit_reason, txn) + .await + .wrap_api_err("executing `insert_exit_message`")?; } Ok(()) diff --git a/apps/labrinth/src/routes/internal/external_notifications.rs b/apps/labrinth/src/routes/internal/external_notifications.rs index 122edfbf79..e43eae6c54 100644 --- a/apps/labrinth/src/routes/internal/external_notifications.rs +++ b/apps/labrinth/src/routes/internal/external_notifications.rs @@ -1,3 +1,5 @@ +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use std::collections::HashMap; use crate::auth::get_user_from_headers; @@ -152,16 +154,23 @@ async fn create_impl( .map(|x| DBUserId(*x)) .collect::>(); - let mut txn = pool.begin().await?; + let mut txn = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; - if !DBUser::exists_many(&user_ids, &mut txn).await? { - return Err(ApiError::InvalidInput( + if !DBUser::exists_many(&user_ids, &mut txn) + .await + .wrap_internal_err("fetching users from database")? + { + return Err(ApiError::Request(eyre::eyre!( "One of the specified users do not exist.".to_owned(), - )); + ))); } // Skip users who already have an identical notification - let body_value = serde_json::value::to_value(&body)?; + let body_value = serde_json::value::to_value(&body) + .wrap_request_err("serializing external notification body")?; let already_notified = sqlx::query!( " SELECT DISTINCT user_id @@ -172,7 +181,8 @@ async fn create_impl( body_value, ) .fetch_all(&mut txn) - .await? + .await + .wrap_internal_err("fetching already notified from database")? .into_iter() .map(|row| DBUserId(row.user_id)) .collect::>(); @@ -188,7 +198,8 @@ async fn create_impl( let notification_ids = if email_strategy == EmailStrategy::Async { notification_builder .insert_many(notification_user_ids, &mut txn, &redis) - .await? + .await + .wrap_internal_err("inserting notification IDs into database")? } else { notification_builder .insert_many_without_delivery( @@ -196,22 +207,31 @@ async fn create_impl( &mut txn, &redis, ) - .await? + .await + .wrap_internal_err("inserting database records for `create_impl`")? }; let notifications = - get_site_exposed_notifications(¬ification_ids, &mut txn).await?; + get_site_exposed_notifications(¬ification_ids, &mut txn) + .await + .wrap_api_err("fetching site exposed notifications")?; - txn.commit().await?; + txn.commit() + .await + .wrap_internal_err("committing database transaction")?; broadcast_notifications(&redis, notifications).await; if email_strategy == EmailStrategy::Sync { - let mut email_txn = pool.begin().await?; + let mut email_txn = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let mut failed = HashMap::new(); let users = DBUser::get_many_ids(&user_ids, &mut email_txn, &redis) - .await? + .await + .wrap_internal_err("fetching users from database")? .into_iter() .map(|user| (user.id, user)) .collect::>(); @@ -242,10 +262,7 @@ async fn create_impl( } } Err(error) => { - if matches!( - error, - ApiError::SqlxDatabase(_) | ApiError::Database(_) - ) { + if matches!(error, ApiError::Internal(_)) { return Err(error); }; failed.insert(user_id, error.into()); @@ -253,7 +270,10 @@ async fn create_impl( }; } - email_txn.commit().await?; + email_txn + .commit() + .await + .wrap_internal_err("committing database transaction")?; let status = if failed .values() @@ -310,14 +330,20 @@ pub async fn remove( .map(|x| DBUserId(x.0 as i64)) .collect::>(); - let mut txn = pool.begin().await?; + let mut txn = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; DBNotification::remove_many_matching_body( &filters, &user_ids, &mut txn, &redis, ) - .await?; + .await + .wrap_internal_err("deleting notifications from database")?; - txn.commit().await?; + txn.commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().finish()) } @@ -350,13 +376,14 @@ pub async fn send_custom_email( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; if user.role != Role::Admin { - return Err(ApiError::CustomAuthentication( - "You do not have permission to send custom emails!".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to send custom emails!", + ))); } let SendEmail { @@ -371,7 +398,10 @@ pub async fn send_custom_email( .map(|x| DBUserId(x.0 as i64)) .collect::>(); - let mut txn = pool.begin().await?; + let mut txn = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; NotificationBuilder { body: NotificationBody::Custom { @@ -381,9 +411,12 @@ pub async fn send_custom_email( }, } .insert_many(users, &mut txn, &redis) - .await?; + .await + .wrap_internal_err("inserting database records for `send_custom_email`")?; - txn.commit().await?; + txn.commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::Accepted().finish()) } @@ -404,13 +437,15 @@ async fn get_site_exposed_notifications( &raw_ids[..], ) .fetch_all(&mut *txn) - .await? + .await + .wrap_internal_err("fetching exposed IDs from database")? .into_iter() .map(DBNotificationId) .collect::>(); Ok(DBNotification::get_many(&exposed_ids, txn) - .await? + .await + .wrap_internal_err("fetching notifications from database")? .into_iter() .map(Notification::from) .collect()) diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index e9effeb083..67f9282431 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -20,6 +20,7 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::routes::internal::session::issue_session; use crate::util::captcha::check_hcaptcha; +use crate::util::error::ApiContext as _; use crate::util::error::Context; use crate::util::ext::get_image_ext; use crate::util::img::upload_image_optimized; @@ -1477,14 +1478,21 @@ pub async fn create_oauth_account( redis: Data, web::Json(new_account): web::Json, ) -> Result { - new_account.validate().map_err(|err| { - ApiError::InvalidInput(validation_errors_to_string(err, None)) - })?; + new_account + .validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; - validate_account_consent(new_account.account_consent)?; + validate_account_consent(new_account.account_consent) + .wrap_api_err("validating account consent")?; - if !check_hcaptcha(&req, &new_account.challenge).await? { - return Err(ApiError::Turnstile); + if !check_hcaptcha(&req, &new_account.challenge) + .await + .wrap_api_err("checking captcha response")? + { + return Err(ApiError::Request(eyre::eyre!( + "captcha validation failed" + ))); } let flow = DBFlow::get(&new_account.state, &redis) @@ -1502,7 +1510,8 @@ pub async fn create_oauth_account( }; if let Some(email) = &user.email { - ensure_email_domain_is_allowed(email)?; + ensure_email_domain_is_allowed(email) + .wrap_api_err("validating email domain is allowed")?; } let mut txn = db @@ -1520,11 +1529,16 @@ pub async fn create_oauth_account( new_account.username, new_account.sign_up_newsletter, ) - .await?; + .await + .wrap_auth_err("inserting user ID into database")?; - let session = issue_session(req, user_id, &mut txn, &redis, None).await?; + let session = issue_session(req, user_id, &mut txn, &redis, None) + .await + .wrap_auth_err("authenticating API request")?; let res = crate::models::sessions::Session::from(session, true, None); - txn.commit().await?; + txn.commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::Ok().json(res)) } @@ -1585,7 +1599,8 @@ pub async fn discord_community_link( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let Some(discord_id) = db_user.discord_id else { @@ -1656,23 +1671,30 @@ pub async fn delete_auth_provider( &session_queue, Scopes::USER_AUTH_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; if user.auth_providers.is_none_or(|x| x.len() <= 1) && !user.has_password.unwrap_or(false) { - return Err(ApiError::InvalidInput( - "You must have another authentication method added to this account!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You must have another authentication method added to this account!", + ))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; delete_provider .provider .update_user_id(user.id.into(), None, &mut transaction) - .await?; + .await + .wrap_auth_err( + "updating database records for `delete_auth_provider`", + )?; if delete_provider.provider != AuthProvider::PayPal { NotificationBuilder { @@ -1681,15 +1703,22 @@ pub async fn delete_auth_provider( }, } .insert(user.id.into(), &mut transaction, &redis) - .await?; + .await + .wrap_internal_err( + "inserting database records for `delete_auth_provider`", + )?; } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; crate::database::models::DBUser::clear_caches( &[(user.id.into(), None)], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::NoContent().finish()) } @@ -1779,6 +1808,18 @@ impl AccountRegisterValidateError { AccountRegisterValidateError::InvalidInput(_) => "invalid_input", } } + + fn into_api_error(self) -> ApiError { + match &self { + Self::UsernameTaken => { + ApiError::Auth(eyre::eyre!(AuthenticationError::UsernameTaken)) + } + Self::DuplicateEmail => { + ApiError::Auth(eyre::eyre!(AuthenticationError::DuplicateEmail)) + } + _ => ApiError::Request(eyre::eyre!("{self}")), + } + } } impl actix_web::ResponseError for AccountRegisterValidateError { @@ -1795,20 +1836,6 @@ impl actix_web::ResponseError for AccountRegisterValidateError { } } -impl From for ApiError { - fn from(value: AccountRegisterValidateError) -> Self { - match &value { - AccountRegisterValidateError::UsernameTaken => { - ApiError::Authentication(AuthenticationError::UsernameTaken) - } - AccountRegisterValidateError::DuplicateEmail => { - ApiError::Authentication(AuthenticationError::DuplicateEmail) - } - _ => ApiError::InvalidInput(value.to_string()), - } - } -} - impl From for AccountRegisterFlow { fn from(account: NewAccount) -> Self { Self { @@ -1862,9 +1889,12 @@ fn ensure_email_domain_is_allowed(email: &str) -> Result<(), ApiError> { } async fn ensure_email_is_usable(email: &str) -> Result<(), ApiError> { - ensure_email_domain_is_allowed(email)?; + ensure_email_domain_is_allowed(email) + .wrap_api_err("validating email domain is allowed")?; - let result = check_email(email).await.map_err(ApiError::Request)?; + let result = check_email(email) + .await + .wrap_request_err("checking email address")?; if matches!( result, @@ -1941,16 +1971,18 @@ impl ReadyAccountRegisterFlow { ) -> Result { let register_flow = self.inner; - let user_id = - crate::database::models::generate_user_id(transaction).await?; + let user_id = crate::database::models::generate_user_id(transaction) + .await + .wrap_internal_err("generating user ID")?; let hasher = Argon2::default(); let salt = SaltString::generate(&mut ChaCha20Rng::from_entropy()); let password_hash = hasher - .hash_password(register_flow.password.as_bytes(), &salt)? + .hash_password(register_flow.password.as_bytes(), &salt) + .wrap_internal_err("hashing password")? .to_string(); - crate::database::models::DBUser { + let insert_result = crate::database::models::DBUser { id: user_id, github_id: None, discord_id: None, @@ -1980,41 +2012,51 @@ impl ReadyAccountRegisterFlow { eligibility_verified_at: Some(Utc::now()), } .insert(transaction) - .await - .map_err(|err| { - if let sqlx::Error::Database(database_error) = &err { - match database_error.constraint() { - Some("username_unique" | "users_username_key") => { - return ApiError::from( - AccountRegisterValidateError::UsernameTaken, - ); - } - Some("email_unique" | "users_email_key") => { - return ApiError::from( - AccountRegisterValidateError::DuplicateEmail, - ); + .await; + match insert_result { + Ok(()) => {} + Err(err) => { + if let sqlx::Error::Database(database_error) = &err { + match database_error.constraint() { + Some("username_unique" | "users_username_key") => { + return Err( + AccountRegisterValidateError::UsernameTaken + .into_api_error(), + ); + } + Some("email_unique" | "users_email_key") => { + return Err( + AccountRegisterValidateError::DuplicateEmail + .into_api_error(), + ); + } + _ => {} } - _ => {} } - } - ApiError::from(err) - })?; + return Err(ApiError::Internal(eyre::eyre!( + "inserting registered user: {err}" + ))); + } + } - let session = - issue_session(req, user_id, transaction, redis, None).await?; + let session = issue_session(req, user_id, transaction, redis, None) + .await + .wrap_auth_err("authenticating API request")?; let res = crate::models::sessions::Session::from(session, true, None); - let mailbox: Mailbox = register_flow.email.parse().map_err(|_| { - ApiError::InvalidInput("Invalid email address!".to_string()) - })?; + let mailbox: Mailbox = register_flow + .email + .parse() + .wrap_request_err("invalid email address!".to_string())?; let flow = DBFlow::ConfirmEmail { user_id, confirm_email: register_flow.email.clone(), } .insert(Duration::hours(24), redis) - .await?; + .await + .wrap_internal_err("storing email-verification flow in Redis")?; email_queue .send_one( @@ -2023,8 +2065,10 @@ impl ReadyAccountRegisterFlow { user_id, mailbox, ) - .await? - .as_user_error()?; + .await + .wrap_api_err("sending account email")? + .as_user_error() + .wrap_api_err("validating email delivery status")?; Ok(res) } @@ -2079,26 +2123,40 @@ pub async fn create_account_with_password( ) -> Result { let new_account = new_account.into_inner(); - validate_account_consent(new_account.account_consent)?; + validate_account_consent(new_account.account_consent) + .wrap_api_err("validating account consent")?; if !check_hcaptcha(&req, new_account.challenge.as_deref().unwrap_or("")) - .await? + .await + .wrap_api_err("checking captcha response")? { - return Err(ApiError::Turnstile); + return Err(ApiError::Request(eyre::eyre!( + "captcha validation failed" + ))); } - ensure_email_is_usable(&new_account.email).await?; + ensure_email_is_usable(&new_account.email) + .await + .wrap_api_err("validating email is usable")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let ready_flow = AccountRegisterFlow::from(new_account) .validate(&mut transaction, &redis) - .await?; + .await + .wrap_internal_err("validating ready flow")?; let res = ready_flow .execute(req, &mut transaction, &redis, &email) - .await?; - transaction.commit().await?; + .await + .wrap_api_err("executing `execute`")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::Ok().json(res)) } @@ -2129,8 +2187,13 @@ pub async fn login_password( redis: Data, login: web::Json, ) -> Result { - if !check_hcaptcha(&req, &login.challenge).await? { - return Err(ApiError::Turnstile); + if !check_hcaptcha(&req, &login.challenge) + .await + .wrap_api_err("checking captcha response")? + { + return Err(ApiError::Request(eyre::eyre!( + "captcha validation failed" + ))); } let user = if let Some(user) = crate::database::models::DBUser::get( @@ -2138,7 +2201,8 @@ pub async fn login_password( &**pool, &redis, ) - .await? + .await + .wrap_internal_err("fetching user from database")? { user } else { @@ -2146,12 +2210,16 @@ pub async fn login_password( &login.username_or_email, &**pool, ) - .await? - .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + .await + .wrap_internal_err("fetching user from database")? + .ok_or_else(|| AuthenticationError::InvalidCredentials) + .wrap_auth_err("fetching user from database")?; crate::database::models::DBUser::get_id(user, &**pool, &redis) - .await? - .ok_or_else(|| AuthenticationError::InvalidCredentials)? + .await + .wrap_internal_err("fetching user from database")? + .ok_or_else(|| AuthenticationError::InvalidCredentials) + .wrap_auth_err("fetching user from database")? }; let hasher = Argon2::default(); @@ -2161,15 +2229,19 @@ pub async fn login_password( &PasswordHash::new( &user .password - .ok_or_else(|| AuthenticationError::InvalidCredentials)?, - )?, + .ok_or_else(|| AuthenticationError::InvalidCredentials) + .wrap_auth_err("authenticating API request")?, + ) + .wrap_internal_err("validating data")?, ) - .map_err(|_| AuthenticationError::InvalidCredentials)?; + .map_err(|_| AuthenticationError::InvalidCredentials) + .wrap_auth_err("authenticating API request")?; if user.totp_secret.is_some() { let flow = DBFlow::Login2FA { user_id: user.id } .insert(Duration::minutes(30), &redis) - .await?; + .await + .wrap_internal_err("inserting authentication flow into database")?; Ok(HttpResponse::Ok().json(serde_json::json!({ "error": "2fa_required", @@ -2177,11 +2249,19 @@ pub async fn login_password( "flow": flow, }))) } else { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let session = - issue_session(req, user.id, &mut transaction, &redis, None).await?; + issue_session(req, user.id, &mut transaction, &redis, None) + .await + .wrap_auth_err("authenticating API request")?; let res = crate::models::sessions::Session::from(session, true, None); - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::Ok().json(res)) } @@ -2285,44 +2365,60 @@ pub async fn login_2fa( login: web::Json, ) -> Result { let flow = DBFlow::get(&login.flow, &redis) - .await? - .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + .await + .wrap_internal_err("fetching login flow from Redis")? + .ok_or_else(|| AuthenticationError::InvalidCredentials) + .wrap_auth_err("finding login flow")?; if let DBFlow::Login2FA { user_id } = flow { let user = crate::database::models::DBUser::get_id(user_id, &**pool, &redis) - .await? - .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + .await + .wrap_internal_err("fetching user from database")? + .ok_or_else(|| AuthenticationError::InvalidCredentials) + .wrap_auth_err("fetching user from database")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; if !validate_2fa_code( login.code.clone(), user.totp_secret - .ok_or_else(|| AuthenticationError::InvalidCredentials)?, + .ok_or_else(|| AuthenticationError::InvalidCredentials) + .wrap_auth_err("authenticating API request")?, true, user.id, &redis, &pool, &mut transaction, ) - .await? + .await + .wrap_auth_err("authenticating API request")? { - return Err(ApiError::Authentication( + return Err(ApiError::Auth(eyre::eyre!( AuthenticationError::InvalidCredentials, - )); + ))); } - DBFlow::remove(&login.flow, &redis).await?; + DBFlow::remove(&login.flow, &redis) + .await + .wrap_internal_err("removing authentication flow from Redis")?; let session = - issue_session(req, user_id, &mut transaction, &redis, None).await?; + issue_session(req, user_id, &mut transaction, &redis, None) + .await + .wrap_auth_err("authenticating API request")?; let res = crate::models::sessions::Session::from(session, true, None); - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::Ok().json(res)) } else { - Err(ApiError::Authentication( + Err(ApiError::Auth(eyre::eyre!( AuthenticationError::InvalidCredentials, - )) + ))) } } @@ -2352,7 +2448,8 @@ pub async fn begin_2fa_flow( &session_queue, Scopes::USER_AUTH_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; if !user.has_totp.unwrap_or(false) { @@ -2364,16 +2461,17 @@ pub async fn begin_2fa_flow( secret: encoded.to_string(), } .insert(Duration::minutes(30), &redis) - .await?; + .await + .wrap_internal_err("inserting database records for `begin_2fa_flow`")?; Ok(HttpResponse::Ok().json(serde_json::json!({ "secret": encoded.to_string(), "flow": flow, }))) } else { - Err(ApiError::InvalidInput( - "User already has 2FA enabled on their account!".to_string(), - )) + Err(ApiError::Request(eyre::eyre!( + "User already has 2FA enabled on their account!", + ))) } } @@ -2398,8 +2496,10 @@ pub async fn finish_2fa_flow( session_queue: Data, ) -> Result { let flow = DBFlow::get(&login.flow, &redis) - .await? - .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + .await + .wrap_internal_err("fetching 2FA initialization flow from Redis")? + .ok_or_else(|| AuthenticationError::InvalidCredentials) + .wrap_auth_err("finding 2FA initialization flow")?; if let DBFlow::Initialize2FA { user_id, secret } = flow { let user = get_user_from_headers( @@ -2409,16 +2509,20 @@ pub async fn finish_2fa_flow( &session_queue, Scopes::USER_AUTH_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; if user.id != user_id.into() { - return Err(ApiError::Authentication( + return Err(ApiError::Auth(eyre::eyre!( AuthenticationError::InvalidCredentials, - )); + ))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; if !validate_2fa_code( login.code.clone(), @@ -2429,14 +2533,17 @@ pub async fn finish_2fa_flow( &pool, &mut transaction, ) - .await? + .await + .wrap_auth_err("authenticating API request")? { - return Err(ApiError::Authentication( + return Err(ApiError::Auth(eyre::eyre!( AuthenticationError::InvalidCredentials, - )); + ))); } - DBFlow::remove(&login.flow, &redis).await?; + DBFlow::remove(&login.flow, &redis) + .await + .wrap_internal_err("removing authentication flow from Redis")?; sqlx::query!( " @@ -2448,7 +2555,8 @@ pub async fn finish_2fa_flow( user_id as crate::database::models::ids::DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `finish_2fa_flow`")?; sqlx::query!( " @@ -2458,7 +2566,8 @@ pub async fn finish_2fa_flow( user_id as crate::database::models::ids::DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `finish_2fa_flow`")?; let mut codes = Vec::new(); @@ -2479,7 +2588,8 @@ pub async fn finish_2fa_flow( val as i64, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `finish_2fa_flow`")?; codes.push(to_base62(val)); } @@ -2488,22 +2598,29 @@ pub async fn finish_2fa_flow( body: NotificationBody::TwoFactorEnabled, } .insert(user.id.into(), &mut transaction, &redis) - .await?; + .await + .wrap_internal_err( + "inserting database records for `finish_2fa_flow`", + )?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; crate::database::models::DBUser::clear_caches( &[(user.id.into(), None)], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::Ok().json(serde_json::json!({ "backup_codes": codes, }))) } else { - Err(ApiError::Authentication( + Err(ApiError::Auth(eyre::eyre!( AuthenticationError::InvalidCredentials, - )) + ))) } } @@ -2540,23 +2657,26 @@ pub async fn remove_2fa( &session_queue, false, ) - .await? - .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + .await + .wrap_auth_err("authenticating API request")? + .ok_or_else(|| AuthenticationError::InvalidCredentials) + .wrap_auth_err("authenticating API request")?; if !scopes.contains(Scopes::USER_AUTH_WRITE) { - return Err(ApiError::Authentication( + return Err(ApiError::Auth(eyre::eyre!( AuthenticationError::InvalidCredentials, - )); + ))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; if !validate_2fa_code( login.code.clone(), - user.totp_secret.ok_or_else(|| { - ApiError::InvalidInput( - "User does not have 2FA enabled on the account!".to_string(), - ) + user.totp_secret.wrap_request_err_with(|| { + "user does not have 2FA enabled on the account!".to_string() })?, true, user.id, @@ -2564,11 +2684,12 @@ pub async fn remove_2fa( &pool, &mut transaction, ) - .await? + .await + .wrap_auth_err("authenticating API request")? { - return Err(ApiError::Authentication( + return Err(ApiError::Auth(eyre::eyre!( AuthenticationError::InvalidCredentials, - )); + ))); } sqlx::query!( @@ -2580,7 +2701,8 @@ pub async fn remove_2fa( user.id as crate::database::models::ids::DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `remove_2fa`")?; sqlx::query!( " @@ -2590,17 +2712,23 @@ pub async fn remove_2fa( user.id as crate::database::models::ids::DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `remove_2fa`")?; NotificationBuilder { body: NotificationBody::TwoFactorRemoved, } .insert(user.id, &mut transaction, &redis) - .await?; + .await + .wrap_internal_err("inserting database records for `remove_2fa`")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; crate::database::models::DBUser::clear_caches(&[(user.id, None)], &redis) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::NoContent().finish()) } @@ -2631,18 +2759,27 @@ pub async fn reset_password_begin( reset_password: web::Json, email: web::Data, ) -> Result { - if !check_hcaptcha(&req, &reset_password.challenge).await? { - return Err(ApiError::Turnstile); + if !check_hcaptcha(&req, &reset_password.challenge) + .await + .wrap_api_err("checking captcha response")? + { + return Err(ApiError::Request(eyre::eyre!( + "captcha validation failed" + ))); } - let mut txn = pool.begin().await?; + let mut txn = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let user = match crate::database::models::DBUser::get_by_case_insensitive_email( &reset_password.username_or_email, &mut txn, ) - .await?[..] + .await + .wrap_internal_err("fetching user from database")?[..] { [] => { // Try finding by username or ID @@ -2651,7 +2788,8 @@ pub async fn reset_password_begin( &mut txn, &redis, ) - .await? + .await + .wrap_internal_err("fetching user from database")? } [user_id] => { // If there is only one user with the given email, ignoring case, @@ -2659,7 +2797,8 @@ pub async fn reset_password_begin( crate::database::models::DBUser::get_id( user_id, &mut txn, &redis, ) - .await? + .await + .wrap_internal_err("fetching user from database")? } _ => { // When several users use variations of the same email with @@ -2671,12 +2810,16 @@ pub async fn reset_password_begin( &reset_password.username_or_email, &mut txn, ) - .await? + .await + .wrap_internal_err( + "fetching password-reset user by email", + )? { crate::database::models::DBUser::get_id( user_id, &mut txn, &redis, ) - .await? + .await + .wrap_internal_err("fetching password-reset user by ID")? } else { None } @@ -2691,7 +2834,8 @@ pub async fn reset_password_begin( { let flow = DBFlow::ForgotPassword { user_id } .insert(Duration::hours(24), &redis) - .await?; + .await + .wrap_internal_err("inserting authentication flow into database")?; if let Ok(mailbox) = user_email.unwrap_or_default().parse() { email @@ -2701,12 +2845,16 @@ pub async fn reset_password_begin( user_id, mailbox, ) - .await? - .as_user_error()?; + .await + .wrap_api_err("sending account email")? + .as_user_error() + .wrap_api_err("validating email delivery status")?; } } - txn.commit().await?; + txn.commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::Ok().finish()) } @@ -2740,20 +2888,24 @@ pub async fn change_password( session_queue: Data, ) -> Result { let user = if let Some(flow) = &change_password.flow { - let flow = DBFlow::get(flow, &redis).await?; + let flow = DBFlow::get(flow, &redis) + .await + .wrap_internal_err("fetching password-reset flow from Redis")?; if let Some(DBFlow::ForgotPassword { user_id }) = flow { let user = crate::database::models::DBUser::get_id( user_id, &**pool, &redis, ) - .await? - .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + .await + .wrap_internal_err("fetching user from database")? + .ok_or_else(|| AuthenticationError::InvalidCredentials) + .wrap_auth_err("fetching user from database")?; 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::Auth(eyre::eyre!( + "The password change flow code is invalid or has expired. Did you copy it promptly and correctly?", + ))); } } else { None @@ -2770,33 +2922,43 @@ pub async fn change_password( &session_queue, false, ) - .await? - .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + .await + .wrap_auth_err("authenticating API request")? + .ok_or_else(|| AuthenticationError::InvalidCredentials) + .wrap_auth_err("authenticating API request")?; if !scopes.contains(Scopes::USER_AUTH_WRITE) { - return Err(ApiError::Authentication( + return Err(ApiError::Auth(eyre::eyre!( AuthenticationError::InvalidCredentials, - )); + ))); } 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() + .wrap_auth_err_with(|| { + "you must specify the old password to change your password!" + .to_string() + })?; let hasher = Argon2::default(); - hasher.verify_password( - old_password.as_bytes(), - &PasswordHash::new(pass)?, - )?; + hasher + .verify_password( + old_password.as_bytes(), + &PasswordHash::new(pass) + .wrap_internal_err("validating data")?, + ) + .wrap_internal_err("validating data")?; } user }; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let update_password = if let Some(new_password) = &change_password.new_password @@ -2807,21 +2969,25 @@ pub async fn change_password( ); if score.score() < Score::Three { - return Err(ApiError::InvalidInput( - if let Some(feedback) = - score.feedback().and_then(|x| x.warning()) - { - format!("Password too weak: {feedback}") - } else { - "Specified password is too weak! Please improve its strength.".to_string() - }, - )); + let report = score + .feedback() + .and_then(|feedback| feedback.warning()) + .map_or_else( + || { + eyre::eyre!( + "Specified password is too weak! Please improve its strength." + ) + }, + |feedback| eyre::eyre!("Password too weak: {feedback}"), + ); + return Err(ApiError::Request(report)); } let hasher = Argon2::default(); let salt = SaltString::generate(&mut ChaCha20Rng::from_entropy()); let password_hash = hasher - .hash_password(new_password.as_bytes(), &salt)? + .hash_password(new_password.as_bytes(), &salt) + .wrap_internal_err("hashing password")? .to_string(); Some(password_hash) @@ -2833,9 +2999,9 @@ pub async fn change_password( || user.steam_id.is_some() || user.discord_id.is_some()) { - return Err(ApiError::InvalidInput( - "You must have another authentication method added to remove password authentication!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You must have another authentication method added to remove password authentication!", + ))); } None @@ -2851,10 +3017,13 @@ pub async fn change_password( user.id as crate::database::models::ids::DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("updating database records for `change_password`")?; if let Some(flow) = &change_password.flow { - DBFlow::remove(flow, &redis).await?; + DBFlow::remove(flow, &redis) + .await + .wrap_internal_err("removing authentication flow from Redis")?; } if update_password.is_some() { @@ -2862,18 +3031,28 @@ pub async fn change_password( body: NotificationBody::PasswordChanged, } .insert(user.id, &mut transaction, &redis) - .await?; + .await + .wrap_internal_err( + "inserting database records for `change_password`", + )?; } else { NotificationBuilder { body: NotificationBody::PasswordRemoved, } .insert(user.id, &mut transaction, &redis) - .await?; + .await + .wrap_internal_err( + "inserting database records for `change_password`", + )?; } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; crate::database::models::DBUser::clear_caches(&[(user.id, None)], &redis) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::Ok().finish()) } @@ -2907,13 +3086,15 @@ pub async fn set_email( session_queue: Data, stripe_client: Data, ) -> Result { - email_address.0.validate().map_err(|err| { - ApiError::InvalidInput(validation_errors_to_string(err, None)) - })?; + email_address + .0 + .validate() + .wrap_request_err("validating request")?; - let mailbox: Mailbox = email_address.email.parse().map_err(|_| { - ApiError::InvalidInput("Invalid email address!".to_string()) - })?; + let mailbox: Mailbox = email_address + .email + .parse() + .wrap_request_err("invalid email address!".to_string())?; let user = get_user_from_headers( &req, @@ -2922,24 +3103,31 @@ pub async fn set_email( &session_queue, Scopes::USER_AUTH_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; if !crate::database::models::DBUser::get_by_case_insensitive_email( &email_address.email, &**pool, ) - .await? + .await + .wrap_internal_err("fetching user from database")? .is_empty() { - return Err(ApiError::InvalidInput( - "Email is already registered on Modrinth! Try 'Forgot password' in incognito to access and delete your other account.".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Email is already registered on Modrinth! Try 'Forgot password' in incognito to access and delete your other account.", + ))); } - ensure_email_is_usable(&email_address.email).await?; + ensure_email_is_usable(&email_address.email) + .await + .wrap_api_err("validating email is usable")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " @@ -2951,7 +3139,8 @@ pub async fn set_email( user.id.0 as i64, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `set_email`")?; if let Some(user_email) = user.email.clone() { NotificationBuilder { @@ -2961,7 +3150,8 @@ pub async fn set_email( }, } .insert(user.id.into(), &mut transaction, &redis) - .await?; + .await + .wrap_internal_err("inserting database records for `set_email`")?; } if let Some(customer_id) = user @@ -2977,7 +3167,8 @@ pub async fn set_email( ..Default::default() }, ) - .await?; + .await + .wrap_failed_dependency_err("communicating with payment provider")?; } let flow = DBFlow::ConfirmEmail { @@ -2985,7 +3176,8 @@ pub async fn set_email( confirm_email: email_address.email.clone(), } .insert(Duration::hours(24), &redis) - .await?; + .await + .wrap_internal_err("inserting database records for `set_email`")?; email .send_one( @@ -2994,16 +3186,22 @@ pub async fn set_email( user.id.into(), mailbox, ) - .await? - .as_user_error()?; + .await + .wrap_api_err("sending account email")? + .as_user_error() + .wrap_api_err("validating email delivery status")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; crate::database::models::DBUser::clear_caches( &[(user.id.into(), None)], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::Ok().finish()) } @@ -3035,14 +3233,15 @@ pub async fn resend_verify_email( &session_queue, Scopes::USER_AUTH_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; if let Some(email_address) = user.email { if user.email_verified.unwrap_or(false) { - return Err(ApiError::InvalidInput( - "User email is already verified!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "User email is already verified!", + ))); } let flow = DBFlow::ConfirmEmail { @@ -3050,13 +3249,19 @@ pub async fn resend_verify_email( confirm_email: email_address.clone(), } .insert(Duration::hours(24), &redis) - .await?; + .await + .wrap_internal_err( + "inserting database records for `resend_verify_email`", + )?; - let mailbox: Mailbox = email_address.parse().map_err(|_| { - ApiError::InvalidInput("Invalid email address!".to_string()) - })?; + let mailbox: Mailbox = email_address + .parse() + .wrap_request_err("invalid email address!".to_string())?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; email .send_one( @@ -3065,16 +3270,21 @@ pub async fn resend_verify_email( user.id.into(), mailbox, ) - .await? - .as_user_error()?; + .await + .wrap_api_err("sending account email")? + .as_user_error() + .wrap_api_err("validating email delivery status")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().finish()) } else { - Err(ApiError::InvalidInput( - "User does not have an email.".to_string(), - )) + Err(ApiError::Request(eyre::eyre!( + "User does not have an email.", + ))) } } @@ -3100,7 +3310,9 @@ pub async fn verify_email( redis: Data, email: web::Json, ) -> Result { - let flow = DBFlow::get(&email.flow, &redis).await?; + let flow = DBFlow::get(&email.flow, &redis) + .await + .wrap_internal_err("fetching email-verification flow from Redis")?; if let Some(DBFlow::ConfirmEmail { user_id, @@ -3109,17 +3321,21 @@ pub async fn verify_email( { let user = crate::database::models::DBUser::get_id(user_id, &**pool, &redis) - .await? - .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + .await + .wrap_internal_err("fetching user from database")? + .ok_or_else(|| AuthenticationError::InvalidCredentials) + .wrap_auth_err("fetching user from database")?; if user.email != Some(confirm_email) { - return Err(ApiError::InvalidInput( - "E-mail does not match verify email. Try re-requesting the verification link." - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "E-mail does not match verify email. Try re-requesting the verification link.", + ))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " @@ -3130,22 +3346,28 @@ pub async fn verify_email( user.id as crate::database::models::ids::DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `verify_email`")?; - DBFlow::remove(&email.flow, &redis).await?; - transaction.commit().await?; + DBFlow::remove(&email.flow, &redis) + .await + .wrap_internal_err("removing authentication flow from Redis")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; crate::database::models::DBUser::clear_caches( &[(user.id, None)], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::NoContent().finish()) } else { - Err(ApiError::InvalidInput( - "Flow does not exist. Try re-requesting the verification link." - .to_string(), - )) + Err(ApiError::Request(eyre::eyre!( + "Flow does not exist. Try re-requesting the verification link.", + ))) } } @@ -3175,7 +3397,8 @@ pub async fn subscribe_newsletter( &session_queue, Scopes::USER_AUTH_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; sqlx::query!( @@ -3187,13 +3410,15 @@ pub async fn subscribe_newsletter( user.id.0 as i64, ) .execute(&**pool) - .await?; + .await + .wrap_internal_err("querying database for `subscribe_newsletter`")?; crate::database::models::DBUser::clear_caches( &[(user.id.into(), None)], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::NoContent().finish()) } @@ -3224,12 +3449,15 @@ pub async fn get_newsletter_subscription_status( &session_queue, Scopes::USER_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let is_subscribed = user.is_subscribed_to_newsletter || if let Some(email) = user.email { - check_sendy_subscription(&email).await? + check_sendy_subscription(&email) + .await + .wrap_auth_err("authenticating API request")? } else { false }; @@ -3275,7 +3503,8 @@ pub async fn register_passkey_start( &session_queue, Scopes::USER_AUTH_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; // Get currently registered credentials, so an authenticator knows not to register @@ -3377,12 +3606,14 @@ pub async fn register_passkey_finish( &session_queue, Scopes::USER_AUTH_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - response.validate().map_err(|err| { - ApiError::InvalidInput(validation_errors_to_string(err, None)) - })?; + response + .validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; let db_user_id: DBUserId = user.id.into(); let flow = DBFlow::take_if( @@ -3390,19 +3621,22 @@ pub async fn register_passkey_finish( |f| matches!(f, DBFlow::RegisterPasskey { user_id, .. } if *user_id == db_user_id), &redis, ) - .await?; + .await.wrap_internal_err("executing `DBFlow::take_if`")?; if let Some(DBFlow::RegisterPasskey { user_id, state }) = flow { if user_id != db_user_id { - return Err(ApiError::Authentication( + return Err(ApiError::Auth(eyre::eyre!( AuthenticationError::InvalidCredentials, - )); + ))); } let result = webauthn .finish_passkey_registration(&response.credential, &state) .wrap_request_err("failed to finish passkey registration")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let existing_passkeys = DBPasskey::get_for_user(db_user_id, &mut transaction) @@ -3431,9 +3665,12 @@ pub async fn register_passkey_finish( passkey .insert(&mut transaction) .await - .wrap_internal_err("Failed to create passkey object")?; + .wrap_internal_err("failed to create passkey object")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(( web::Json(PasskeyResponse { id: passkey.id.into(), @@ -3524,7 +3761,8 @@ pub async fn authenticate_passkey_finish( |f| matches!(f, DBFlow::AuthenticatePasskey { .. }), &redis, ) - .await?; + .await + .wrap_internal_err("executing `DBFlow::take_if`")?; if let Some(DBFlow::AuthenticatePasskey { state }) = flow { let credential_id = response.credential.get_credential_id(); @@ -3532,7 +3770,7 @@ pub async fn authenticate_passkey_finish( DBPasskey::get_by_credential_id(credential_id, &**pool) .await .wrap_internal_err("failed to fetch passkey")? - .ok_or_else(|| ApiError::Request(eyre!("passkey not found")))?; + .wrap_request_err_with(|| "passkey not found")?; let mut transaction = pool .begin() @@ -3559,7 +3797,10 @@ pub async fn authenticate_passkey_finish( ) .await .wrap_internal_err("failed to invalidate user sessions")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; DBSession::clear_cache( sessions .into_iter() @@ -3605,10 +3846,14 @@ pub async fn authenticate_passkey_finish( &redis, None, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let res = crate::models::sessions::Session::from(session, true, None); - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(web::Json(res)) } else { Err(ApiError::Request(eyre!( @@ -3643,7 +3888,8 @@ pub async fn list_passkeys( &session_queue, Scopes::USER_AUTH_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let passkeys = DBPasskey::get_for_user(user.id.into(), &**pool) @@ -3696,29 +3942,36 @@ pub async fn rename_passkey( &session_queue, Scopes::USER_AUTH_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - body.validate().map_err(|err| { - ApiError::InvalidInput(validation_errors_to_string(err, None)) - })?; + body.validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; let id = DBPasskeyId( parse_base62(&info.into_inner().0) .wrap_request_err("invalid passkey id")? as i64, ); - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let found = DBPasskey::rename(id, user.id.into(), &body.name, &mut transaction) .await .wrap_internal_err("failed to rename passkey")?; if !found { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().finish()) } @@ -3749,7 +4002,8 @@ pub async fn delete_passkey( &session_queue, Scopes::USER_AUTH_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let id = DBPasskeyId( @@ -3757,16 +4011,22 @@ pub async fn delete_passkey( .wrap_request_err("invalid passkey id")? as i64, ); - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let found = DBPasskey::remove_for_user(id, user.id.into(), &mut transaction) .await .wrap_internal_err("failed to delete passkey")?; if !found { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().finish()) } diff --git a/apps/labrinth/src/routes/internal/gdpr.rs b/apps/labrinth/src/routes/internal/gdpr.rs index de25c7746d..479209bd17 100644 --- a/apps/labrinth/src/routes/internal/gdpr.rs +++ b/apps/labrinth/src/routes/internal/gdpr.rs @@ -3,6 +3,7 @@ use crate::database::PgPool; use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::util::error::Context as _; use actix_web::{HttpRequest, HttpResponse, post, web}; use xredis::RedisPool; @@ -30,46 +31,52 @@ pub async fn export( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let user_id = user.id.into(); let collection_ids = crate::database::models::DBUser::get_collections(user_id, &**pool) - .await?; + .await + .wrap_internal_err("fetching users from database")?; let collections = crate::database::models::DBCollection::get_many( &collection_ids, &**pool, &redis, ) - .await? + .await + .wrap_internal_err("fetching collections from database")? .into_iter() .map(crate::models::collections::Collection::from) .collect::>(); let follows = crate::database::models::DBUser::get_follows(user_id, &**pool) - .await? + .await + .wrap_internal_err("fetching users from database")? .into_iter() .map(crate::models::ids::ProjectId::from) .collect::>(); let projects = crate::database::models::DBUser::get_projects(user_id, &**pool, &redis) - .await? + .await + .wrap_internal_err("fetching users from database")? .into_iter() .map(crate::models::ids::ProjectId::from) .collect::>(); let org_ids = crate::database::models::DBUser::get_organizations(user_id, &**pool) - .await?; + .await + .wrap_internal_err("fetching users from database")?; let orgs = crate::database::models::organization_item::DBOrganization::get_many_ids( &org_ids, &**pool, &redis, ) - .await? + .await.wrap_internal_err("fetching organizations from database")? .into_iter() // TODO: add team members .map(|x| crate::models::organizations::Organization::from(x, vec![])) @@ -78,7 +85,7 @@ pub async fn export( let notifs = crate::database::models::notification_item::DBNotification::get_all_user( user_id, &**pool, ) - .await? + .await.wrap_internal_err("fetching notifications from database")? .into_iter() .map(crate::models::notifications::Notification::from) .collect::>(); @@ -86,7 +93,7 @@ pub async fn export( let notifs_deliveries = crate::database::models::notifications_deliveries_item::DBNotificationDelivery::get_all_user( user_id, &**pool, ) - .await? + .await.wrap_internal_err("fetching notification deliveries from database")? .into_iter() .map(crate::models::notifications::NotificationDelivery::from) .collect::>(); @@ -95,7 +102,7 @@ pub async fn export( crate::database::models::oauth_client_item::DBOAuthClient::get_all_user_clients( user_id, &**pool, ) - .await? + .await.wrap_internal_err("fetching OAuth clients from database")? .into_iter() .map(crate::models::oauth_clients::OAuthClient::from) .collect::>(); @@ -103,7 +110,7 @@ pub async fn export( let oauth_authorizations = crate::database::models::oauth_client_authorization_item::DBOAuthClientAuthorization::get_all_for_user( user_id, &**pool, ) - .await? + .await.wrap_internal_err("fetching OAuth client authorizations from database")? .into_iter() .map(crate::models::oauth_clients::OAuthClientAuthorization::from) .collect::>(); @@ -112,12 +119,13 @@ pub async fn export( crate::database::models::pat_item::DBPersonalAccessToken::get_user_pats( user_id, &**pool, &redis, ) - .await?; + .await.wrap_internal_err("fetching personal access tokens from database")?; let pats = crate::database::models::pat_item::DBPersonalAccessToken::get_many_ids( &pat_ids, &**pool, &redis, ) - .await? + .await + .wrap_internal_err("fetching personal access tokens from database")? .into_iter() .map(|x| crate::models::pats::PersonalAccessToken::from(x, false)) .collect::>(); @@ -126,13 +134,15 @@ pub async fn export( crate::database::models::payout_item::DBPayout::get_all_for_user( user_id, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching payouts from database")?; let payouts = crate::database::models::payout_item::DBPayout::get_many( &payout_ids, &**pool, ) - .await? + .await + .wrap_internal_err("fetching payouts from database")? .into_iter() .map(crate::models::payouts::Payout::from) .collect::>(); @@ -140,12 +150,14 @@ pub async fn export( let report_ids = crate::database::models::user_item::DBUser::get_reports( user_id, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching users from database")?; let reports = crate::database::models::report_item::DBReport::get_many( &report_ids, &**pool, ) - .await? + .await + .wrap_internal_err("fetching reports from database")? .into_iter() .map(crate::models::reports::Report::from) .collect::>(); @@ -157,7 +169,7 @@ pub async fn export( user_id.0 ) .fetch_all(pool.as_ref()) - .await? + .await.wrap_internal_err("fetching message IDs from database")? .into_iter() .map(|x| crate::database::models::ids::DBThreadMessageId(x.id)) .collect::>(); @@ -167,7 +179,8 @@ pub async fn export( &message_ids, &**pool, ) - .await? + .await + .wrap_internal_err("fetching thread messages from database")? .into_iter() .map(|x| crate::models::threads::ThreadMessage::from(x, &user)) .collect::>(); @@ -177,7 +190,8 @@ pub async fn export( user_id.0 ) .fetch_all(pool.as_ref()) - .await? + .await + .wrap_internal_err("fetching uploaded images IDs from database")? .into_iter() .map(|x| crate::database::models::ids::DBImageId(x.id)) .collect::>(); @@ -188,7 +202,8 @@ pub async fn export( &**pool, &redis, ) - .await? + .await + .wrap_internal_err("fetching images from database")? .into_iter() .map(crate::models::images::Image::from) .collect::>(); @@ -197,7 +212,7 @@ pub async fn export( crate::database::models::user_subscription_item::DBUserSubscription::get_all_user( user_id, &**pool, ) - .await? + .await.wrap_internal_err("fetching user subscriptions from database")? .into_iter() .map(crate::models::billing::UserSubscription::from) .collect::>(); diff --git a/apps/labrinth/src/routes/internal/medal.rs b/apps/labrinth/src/routes/internal/medal.rs index 67c8f637dc..c443e19e75 100644 --- a/apps/labrinth/src/routes/internal/medal.rs +++ b/apps/labrinth/src/routes/internal/medal.rs @@ -1,4 +1,5 @@ use crate::database::PgPool; +use crate::util::error::Context as _; use actix_web::{HttpResponse, post, web}; use ariadne::ids::UserId; use chrono::Utc; @@ -45,10 +46,11 @@ pub async fn verify( &username, Offer::Medal, ) - .await?; + .await + .wrap_internal_err("executing `RedeemalLookupFields::redeemal_status_by_username_and_offer`")?; match maybe_fields { - None => Err(ApiError::NotFound), + None => Err(ApiError::NotFound(eyre::eyre!("resource not found"))), Some(fields) => Ok(HttpResponse::Ok().json(VerifyResponse { user_id: fields.user_id.into(), redeemed: fields.redeemal_status.is_some(), @@ -71,7 +73,10 @@ pub async fn redeem( // Check the offer hasn't been redeemed yet, then insert into the table. // In a transaction to avoid double inserts. - let mut txn = pool.begin().await?; + let mut txn = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let maybe_fields = RedeemalLookupFields::redeemal_status_by_username_and_offer( @@ -79,15 +84,18 @@ pub async fn redeem( &username, Offer::Medal, ) - .await?; + .await + .wrap_internal_err("executing `RedeemalLookupFields::redeemal_status_by_username_and_offer`")?; let user_id = match maybe_fields { - None => return Err(ApiError::NotFound), + None => { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); + } Some(fields) => { if fields.redeemal_status.is_some() { - return Err(ApiError::Conflict( - "User already redeemed this offer".to_string(), - )); + return Err(ApiError::Conflict(eyre::eyre!( + "User already redeemed this offer", + ))); } fields.user_id @@ -105,9 +113,14 @@ pub async fn redeem( n_attempts: 0, }; - redeemal.insert(&mut txn).await?; + redeemal + .insert(&mut txn) + .await + .wrap_internal_err("inserting database records for `redeem`")?; - txn.commit().await?; + txn.commit() + .await + .wrap_internal_err("committing database transaction")?; // Immediately try to process the redeemal if let Err(error) = try_process_user_redeemal(&pool, &redis, redeemal).await diff --git a/apps/labrinth/src/routes/internal/moderation/external_license.rs b/apps/labrinth/src/routes/internal/moderation/external_license.rs index 493a866b59..84ae825b04 100644 --- a/apps/labrinth/src/routes/internal/moderation/external_license.rs +++ b/apps/labrinth/src/routes/internal/moderation/external_license.rs @@ -1,3 +1,5 @@ +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use std::collections::HashMap; use actix_web::{HttpRequest, get, patch, post, web}; @@ -99,9 +101,9 @@ impl LicenseId { match self { LicenseId::Number(id) => Ok(id), LicenseId::String(id) => id.parse().map_err(|_| { - ApiError::InvalidInput( - "license_id must be a valid integer".to_string(), - ) + ApiError::Request(eyre::eyre!( + "license_id must be a valid integer", + )) }), } } @@ -143,9 +145,9 @@ fn normalize_sha1_hashes(hashes: &[String]) -> Result, ApiError> { let hash = hash.trim().to_lowercase(); if hash.len() != 40 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(ApiError::InvalidInput( - "hash must be a valid SHA1 hex string".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "hash must be a valid SHA1 hex string", + ))); } Ok(hash) @@ -196,7 +198,8 @@ async fn fetch_linked_files( license_ids, ) .fetch_all(pool) - .await?; + .await + .wrap_internal_err("fetching file rows from database")?; let mut map: HashMap> = HashMap::new(); for row in file_rows { @@ -247,10 +250,12 @@ async fn fetch_by_hashes( &hash_bytes, ) .fetch_all(pool) - .await?; + .await.wrap_internal_err("querying database for `fetch_by_hashes`")?; let license_ids = rows.iter().map(|row| row.id).collect::>(); - let files_map = fetch_linked_files(pool, &license_ids).await?; + let files_map = fetch_linked_files(pool, &license_ids) + .await + .wrap_api_err("fetching linked files")?; let mut results = HashMap::new(); for row in rows { @@ -309,10 +314,13 @@ async fn fetch_by_flame_ids( flame_ids, ) .fetch_all(pool) - .await?; + .await + .wrap_internal_err("querying database for `fetch_by_flame_ids`")?; let license_ids = rows.iter().map(|row| row.id).collect::>(); - let files_map = fetch_linked_files(pool, &license_ids).await?; + let files_map = fetch_linked_files(pool, &license_ids) + .await + .wrap_api_err("fetching linked files")?; let mut results: HashMap> = HashMap::new(); for row in rows { @@ -350,7 +358,8 @@ pub async fn search( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating external license search")?; let rows = sqlx::query_as!( LicenseRow, @@ -381,10 +390,13 @@ pub async fn search( body.flame_ids.as_deref(), ) .fetch_all(&**pool) - .await?; + .await + .wrap_internal_err("querying database for `search`")?; let license_ids: Vec = rows.iter().map(|r| r.id).collect(); - let files_map = fetch_linked_files(&pool, &license_ids).await?; + let files_map = fetch_linked_files(&pool, &license_ids) + .await + .wrap_api_err("fetching linked files")?; let results = rows .into_iter() @@ -419,12 +431,18 @@ pub async fn lookup( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let body = body.into_inner(); - let hashes = normalize_sha1_hashes(&body.hashes)?; - let flame_ids = fetch_by_flame_ids(&pool, &body.flame_ids).await?; - let hashes = fetch_by_hashes(&pool, &hashes).await?; + let hashes = normalize_sha1_hashes(&body.hashes) + .wrap_api_err("executing `normalize_sha1_hashes`")?; + let flame_ids = fetch_by_flame_ids(&pool, &body.flame_ids) + .await + .wrap_api_err("fetching by flame ids")?; + let hashes = fetch_by_hashes(&pool, &hashes) + .await + .wrap_api_err("fetching by hashes")?; Ok(web::Json(ExternalLicenseLookupResponse { flame_ids, @@ -453,12 +471,18 @@ pub async fn get_by_sha1( &session_queue, Scopes::PROJECT_READ, ) - .await?; - - let hashes = normalize_sha1_hashes(&[path.into_inner().0])?; - let hash = hashes.first().ok_or(ApiError::NotFound)?; - let mut results = fetch_by_hashes(&pool, &hashes).await?; - let result = results.remove(hash).ok_or(ApiError::NotFound)?; + .await + .wrap_auth_err("authenticating API request")?; + + let hashes = normalize_sha1_hashes(&[path.into_inner().0]) + .wrap_api_err("normalizing SHA-1 hash")?; + let hash = hashes.first().wrap_not_found_err("resource not found")?; + let mut results = fetch_by_hashes(&pool, &hashes) + .await + .wrap_api_err("fetching by hashes")?; + let result = results + .remove(hash) + .wrap_not_found_err("resource not found")?; Ok(web::Json(result)) } @@ -484,10 +508,14 @@ pub async fn get_by_sha1_bulk( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; - let hashes = normalize_sha1_hashes(&body.hashes)?; - let results = fetch_by_hashes(&pool, &hashes).await?; + let hashes = normalize_sha1_hashes(&body.hashes) + .wrap_api_err("executing `normalize_sha1_hashes`")?; + let results = fetch_by_hashes(&pool, &hashes) + .await + .wrap_api_err("fetching by hashes")?; Ok(web::Json(results)) } @@ -540,16 +568,21 @@ async fn upsert_file_license( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let body = body.into_inner(); - let license_id = body.license_id.parse()?; + let license_id = body + .license_id + .parse() + .wrap_api_err("parsing external license ID")?; if body.hashes.is_empty() { - return Err(ApiError::InvalidInput( - "hashes must contain at least one SHA1 hex string".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "hashes must contain at least one SHA1 hex string", + ))); } - let hashes = normalize_sha1_hashes(&body.hashes)?; + let hashes = normalize_sha1_hashes(&body.hashes) + .wrap_api_err("executing `normalize_sha1_hashes`")?; let hash_bytes = hashes .iter() .map(|hash| hash.as_bytes().to_vec()) @@ -557,7 +590,10 @@ async fn upsert_file_license( let filenames = vec![None; hashes.len()]; let license_ids = vec![license_id; hashes.len()]; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let license = sqlx::query!( r#" @@ -579,8 +615,9 @@ async fn upsert_file_license( license_id, ) .fetch_optional(&mut transaction) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_internal_err("fetching license from database")? + .wrap_not_found_err("resource not found")?; ExternalLicense::insert_files( &mut transaction, @@ -589,11 +626,19 @@ async fn upsert_file_license( &license_ids, DBUserId(user.id.0 as i64), ) - .await?; - - transaction.commit().await?; - - let files_map = fetch_linked_files(&pool, &[license_id]).await?; + .await + .wrap_internal_err( + "inserting database records for `upsert_file_license`", + )?; + + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; + + let files_map = fetch_linked_files(&pool, &[license_id]) + .await + .wrap_api_err("fetching linked files")?; let linked_files = files_map.get(&license_id).cloned().unwrap_or_default(); Ok(web::Json( @@ -636,7 +681,8 @@ pub async fn update_license( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let id = path.into_inner().0; @@ -666,10 +712,13 @@ pub async fn update_license( user.id.0 as i64, ) .fetch_optional(&**pool) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_internal_err("querying database for `update_license`")? + .wrap_not_found_err("resource not found")?; - let files_map = fetch_linked_files(&pool, &[id]).await?; + let files_map = fetch_linked_files(&pool, &[id]) + .await + .wrap_api_err("fetching linked files")?; let linked_files = files_map.get(&id).cloned().unwrap_or_default(); Ok(web::Json( diff --git a/apps/labrinth/src/routes/internal/moderation/mod.rs b/apps/labrinth/src/routes/internal/moderation/mod.rs index e66f0e0f84..38777440f7 100644 --- a/apps/labrinth/src/routes/internal/moderation/mod.rs +++ b/apps/labrinth/src/routes/internal/moderation/mod.rs @@ -8,6 +8,7 @@ use crate::models::ids::{OrganizationId, ProjectId}; use crate::models::projects::{ProjectStatus, VersionStatus}; use crate::queue::moderation::{ApprovalType, IdentifiedFile, MissingMetadata}; use crate::queue::session::AuthQueue; +use crate::util::error::ApiContext as _; use crate::util::error::Context; use crate::{ auth::{check_is_moderator_from_headers, get_user_from_bearer_token}, @@ -254,7 +255,8 @@ pub async fn get_projects_internal( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let request_opts = request_opts.into_inner(); let query = normalize_optional_string(request_opts.query.as_deref()); @@ -518,7 +520,9 @@ pub async fn get_projects_internal( row.owner_icon_url, row.project_types, row.external_dependencies_count, - )? { + ) + .wrap_api_err("executing `row_to_queue_project`")? + { projects.push(project); } } @@ -693,7 +697,9 @@ pub async fn get_projects_internal( row.owner_icon_url, row.project_types, row.external_dependencies_count, - )? { + ) + .wrap_api_err("executing `row_to_queue_project`")? + { projects.push(project); } } @@ -731,7 +737,8 @@ pub async fn get_project_ids( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let request_opts = request_opts.into_inner(); let query = normalize_optional_string(request_opts.query.as_deref()); @@ -978,7 +985,8 @@ fn row_to_queue_project( owner_id, owner_name, owner_icon_url, - )?; + ) + .wrap_api_err("executing `row_to_ownership`")?; Ok(Some(ModerationQueueProject { id: project_id, @@ -1076,11 +1084,14 @@ pub async fn get_project_meta( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let project_id = info.into_inner().0; let project = - database::models::DBProject::get(&project_id, &**pool, &redis).await?; + database::models::DBProject::get(&project_id, &**pool, &redis) + .await + .wrap_api_err("fetching project from database")?; if let Some(project) = project { let rows = sqlx::query!( @@ -1094,7 +1105,8 @@ pub async fn get_project_meta( project.inner.id.0 ) .fetch_all(&**pool) - .await?; + .await + .wrap_internal_err("querying database for `get_project_meta`")?; let mut merged = MissingMetadata { identified: HashMap::new(), @@ -1134,7 +1146,7 @@ pub async fn get_project_meta( .collect::>() ) .fetch_all(&**pool) - .await?; + .await.wrap_internal_err("querying database for `get_project_meta`")?; for row in rows { if let Some(sha1) = row.sha1 { @@ -1169,7 +1181,8 @@ pub async fn get_project_meta( &check_flames, ) .fetch_all(&**pool) - .await?; + .await + .wrap_internal_err("querying database for `get_project_meta`")?; for row in rows { if let Some(sha1) = merged @@ -1192,7 +1205,7 @@ pub async fn get_project_meta( Ok(web::Json(merged)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -1234,9 +1247,13 @@ pub async fn set_project_meta( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let mut licenses = Vec::new(); let mut file_hashes = Vec::new(); @@ -1287,7 +1304,8 @@ pub async fn set_project_meta( &licenses, user_id, ) - .await?; + .await + .wrap_internal_err("inserting database records for `set_project_meta`")?; moderation_external_item::ExternalLicense::insert_files( &mut transaction, @@ -1299,9 +1317,13 @@ pub async fn set_project_meta( &file_license_ids, user_id, ) - .await?; + .await + .wrap_internal_err("inserting database records for `set_project_meta`")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(()) } @@ -1331,18 +1353,23 @@ pub async fn acquire_lock( &session_queue, Scopes::PROJECT_WRITE, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let project_id_str = path.into_inner().0; let project = database::models::DBProject::get(&project_id_str, &**pool, &redis) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_api_err("fetching project from database")? + .wrap_not_found_err("resource not found")?; let db_project_id = project.inner.id; let db_user_id = database::models::DBUserId::from(user.id); - match DBModerationLock::acquire(db_project_id, db_user_id, &pool).await? { + match DBModerationLock::acquire(db_project_id, db_user_id, &pool) + .await + .wrap_internal_err("executing `DBModerationLock::acquire`")? + { Ok(()) => Ok(web::Json(LockAcquireResponse { success: true, is_own_lock: true, @@ -1390,18 +1417,22 @@ pub async fn override_lock( &session_queue, Scopes::PROJECT_WRITE, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let project_id_str = path.into_inner().0; let project = database::models::DBProject::get(&project_id_str, &**pool, &redis) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_api_err("fetching project from database")? + .wrap_not_found_err("resource not found")?; let db_project_id = project.inner.id; let db_user_id = database::models::DBUserId::from(user.id); - DBModerationLock::force_acquire(db_project_id, db_user_id, &pool).await?; + DBModerationLock::force_acquire(db_project_id, db_user_id, &pool) + .await + .wrap_internal_err("executing `DBModerationLock::force_acquire`")?; Ok(web::Json(LockAcquireResponse { success: true, @@ -1437,18 +1468,23 @@ pub async fn get_lock_status( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let project_id_str = path.into_inner().0; let project = database::models::DBProject::get(&project_id_str, &**pool, &redis) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_api_err("fetching project from database")? + .wrap_not_found_err("resource not found")?; let db_project_id = project.inner.id; let db_user_id = database::models::DBUserId::from(user.id); - match DBModerationLock::get_with_user(db_project_id, &pool).await? { + match DBModerationLock::get_with_user(db_project_id, &pool) + .await + .wrap_internal_err("fetching moderation lock from database")? + { Some(lock) => { let is_own_lock = lock.moderator_id == db_user_id; Ok(web::Json(LockStatusResponse { @@ -1499,19 +1535,22 @@ pub async fn release_lock( &session_queue, Scopes::PROJECT_WRITE, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let project_id_str = path.into_inner().0; let project = database::models::DBProject::get(&project_id_str, &**pool, &redis) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_api_err("fetching project from database")? + .wrap_not_found_err("resource not found")?; let db_project_id = project.inner.id; let db_user_id = database::models::DBUserId::from(user.id); - let released = - DBModerationLock::release(db_project_id, db_user_id, &pool).await?; + let released = DBModerationLock::release(db_project_id, db_user_id, &pool) + .await + .wrap_internal_err("executing `DBModerationLock::release`")?; let _ = DBModerationLock::cleanup_expired(&pool).await; @@ -1547,9 +1586,9 @@ pub async fn release_lock_beacon( ) -> Result, ApiError> { let token = body.trim(); if token.is_empty() { - return Err(ApiError::InvalidInput( - "missing token in request body".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "missing token in request body", + ))); } let token = token.strip_prefix("Bearer ").unwrap_or(token).trim(); @@ -1561,30 +1600,33 @@ pub async fn release_lock_beacon( &session_queue, false, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; if !scopes.contains(Scopes::PROJECT_WRITE) { - return Err(ApiError::CustomAuthentication( - "token is missing required scopes".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "token is missing required scopes", + ))); } if !user.role.is_mod() { - return Err(ApiError::CustomAuthentication( - "only moderators may release moderation locks".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "only moderators may release moderation locks", + ))); } let project_id_str = path.into_inner().0; let project = database::models::DBProject::get(&project_id_str, &**pool, &redis) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_api_err("fetching project from database")? + .wrap_not_found_err("resource not found")?; let db_project_id = project.inner.id; let db_user_id = database::models::DBUserId::from(user.id); - let released = - DBModerationLock::release(db_project_id, db_user_id, &pool).await?; + let released = DBModerationLock::release(db_project_id, db_user_id, &pool) + .await + .wrap_internal_err("executing `DBModerationLock::release`")?; let _ = DBModerationLock::cleanup_expired(&pool).await; @@ -1614,16 +1656,19 @@ pub async fn delete_all_locks( &session_queue, Scopes::PROJECT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; if !user.role.is_admin() { - return Err(ApiError::CustomAuthentication( - "You must be an admin to delete all locks".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You must be an admin to delete all locks", + ))); } - let deleted_count = DBModerationLock::delete_all(&pool).await?; + let deleted_count = DBModerationLock::delete_all(&pool) + .await + .wrap_internal_err("deleting moderation locks from database")?; Ok(web::Json(DeleteAllLocksResponse { deleted_count })) } diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs index ea694627eb..c55d8fdbc7 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs @@ -1,3 +1,4 @@ +use crate::util::error::ApiContext as _; use std::{collections::HashMap, fmt}; use xredis::RedisPool; @@ -225,7 +226,8 @@ pub async fn get_issue( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let (issue_id,) = path.into_inner(); let row = sqlx::query!( @@ -261,7 +263,7 @@ pub async fn get_issue( .fetch_optional(&**pool) .await .wrap_internal_err("failed to fetch issue from database")? - .ok_or(ApiError::NotFound)?; + .wrap_not_found_err("resource not found")?; Ok(web::Json(row.data.0)) } @@ -288,7 +290,8 @@ pub async fn get_report( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let (report_id,) = path.into_inner(); @@ -348,7 +351,7 @@ pub async fn get_report( .fetch_optional(&**pool) .await .wrap_internal_err("failed to fetch report from database")? - .ok_or(ApiError::NotFound)?; + .wrap_not_found_err("resource not found")?; Ok(web::Json(row.data.0)) } @@ -703,7 +706,8 @@ pub async fn search_projects( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let sort_by = search_req.sort_by.to_string(); let limit = search_req.limit.max(50); @@ -819,8 +823,9 @@ pub async fn search_projects( thread_ids.push(row.thread_id); } - let project_reports = - fetch_project_reports(&project_ids, &pool, &redis).await?; + let project_reports = fetch_project_reports(&project_ids, &pool, &redis) + .await + .wrap_api_err("fetching project reports")?; let projects = DBProject::get_many_ids(&project_ids, &**pool, &redis) .await @@ -913,7 +918,8 @@ pub async fn get_project_report( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let (project_id,) = path.into_inner(); let db_project_id = DBProjectId::from(project_id); @@ -929,10 +935,12 @@ pub async fn get_project_report( .fetch_optional(&**pool) .await .wrap_internal_err("failed to fetch thread")? - .ok_or(ApiError::NotFound)?; + .wrap_not_found_err("resource not found")?; let project_reports = - fetch_project_reports(&[db_project_id], &pool, &redis).await?; + fetch_project_reports(&[db_project_id], &pool, &redis) + .await + .wrap_api_err("fetching project reports")?; let project_report = project_reports.into_iter().next(); @@ -966,7 +974,7 @@ pub async fn get_project_report( let thread = threads .get(&row.thread_id.into()) .cloned() - .ok_or(ApiError::NotFound)?; + .wrap_not_found_err("resource not found")?; Ok(web::Json(ProjectReportResponse { project_report, @@ -988,8 +996,8 @@ pub struct SubmitReport { /// /// Before this is called, all issues for this project's reports must have been /// marked as either safe or unsafe. Otherwise, this will error with -/// [`ApiError::TechReviewIssuesWithNoVerdict`], providing the issue IDs which -/// are still unmarked. +/// A request error is returned with the issue detail IDs which are still +/// unmarked. #[utoipa::path( context_path = "/moderation/tech-review", tag = "moderation", @@ -1013,7 +1021,8 @@ pub async fn submit_report( &session_queue, Scopes::PROJECT_WRITE, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let (project_id,) = path.into_inner(); let project_id = DBProjectId::from(project_id); @@ -1045,14 +1054,13 @@ pub async fn submit_report( .wrap_internal_err("failed to fetch pending issues")?; if !pending_issue_details.is_empty() { - return Err(ApiError::TechReviewDetailsWithNoVerdict { - details: pending_issue_details - .into_iter() - .map(|record| { - DelphiReportIssueDetailsId(record.issue_detail_id) - }) - .collect(), - }); + let details = pending_issue_details + .into_iter() + .map(|record| DelphiReportIssueDetailsId(record.issue_detail_id)) + .collect_vec(); + return Err(ApiError::Request(eyre::eyre!( + "report still has issue details with no verdict: {details:?}" + ))); } sqlx::query!( @@ -1171,7 +1179,10 @@ pub async fn submit_report( None, None, ) - .await?; + .await + .wrap_api_err( + "executing `projects::clear_project_cache_and_queue_search`", + )?; } Ok(()) @@ -1224,7 +1235,8 @@ pub async fn update_issue_details( &session_queue, Scopes::PROJECT_WRITE, ) - .await?; + .await + .wrap_auth_err("updating database records for `update_issue_details`")?; let mut txn = pool .begin() @@ -1347,7 +1359,10 @@ pub async fn update_issue_details( TechReviewExitReason::Resolved, &mut txn, ) - .await?; + .await + .wrap_api_err( + "executing `tech_review_sync::sync_project_tech_review_state`", + )?; txn.commit() .await @@ -1381,7 +1396,10 @@ pub async fn update_global_issue_details( &session_queue, Scopes::PROJECT_WRITE, ) - .await?; + .await + .wrap_auth_err( + "updating database records for `update_global_issue_details`", + )?; let updates = update_reqs.into_inner(); @@ -1461,7 +1479,10 @@ pub async fn update_global_issue_details( TechReviewExitReason::Resolved, &mut txn, ) - .await?; + .await + .wrap_api_err( + "executing `tech_review_sync::sync_detail_key_tech_review_state`", + )?; txn.commit() .await @@ -1498,7 +1519,8 @@ pub async fn add_report( &session_queue, Scopes::PROJECT_WRITE, ) - .await?; + .await + .wrap_auth_err("inserting database records for `add_report`")?; let file_id = add_report.file_id; let mut txn = pool diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/global.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/global.rs index c152d5c2f9..84fbe00fe3 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review/global.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review/global.rs @@ -148,7 +148,8 @@ pub async fn search_global_issue_details( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating global issue search")?; let query = search_req .query @@ -370,7 +371,8 @@ pub async fn get_global_issue_detail( &session_queue, Scopes::PROJECT_READ, ) - .await?; + .await + .wrap_auth_err("authenticating global issue detail request")?; let detail_key = get_req.detail_key.trim(); if detail_key.is_empty() { @@ -404,7 +406,7 @@ pub async fn get_global_issue_detail( .fetch_optional(&**pool) .await .wrap_internal_err("failed to fetch global issue detail")? - .ok_or(ApiError::NotFound)?; + .wrap_not_found_err("resource not found")?; let local_rows = sqlx::query!( r#" diff --git a/apps/labrinth/src/routes/internal/mural.rs b/apps/labrinth/src/routes/internal/mural.rs index b78069de5c..ea29c29e38 100644 --- a/apps/labrinth/src/routes/internal/mural.rs +++ b/apps/labrinth/src/routes/internal/mural.rs @@ -22,7 +22,7 @@ pub async fn get_bank_details( let mural = payouts_queue.muralpay.load(); let mural = mural .as_ref() - .wrap_internal_err("Mural API not available")?; + .wrap_internal_err("required Mural API is not available")?; let fiat_and_rail_codes = FiatAndRailCode::iter().collect::>(); let details = mural .client diff --git a/apps/labrinth/src/routes/internal/pats.rs b/apps/labrinth/src/routes/internal/pats.rs index 5c0d41b783..63e4f10eba 100644 --- a/apps/labrinth/src/routes/internal/pats.rs +++ b/apps/labrinth/src/routes/internal/pats.rs @@ -1,5 +1,6 @@ use crate::database; use crate::database::models::generate_pat_id; +use crate::util::error::Context as _; use crate::auth::get_user_from_headers; use crate::routes::ApiError; @@ -18,7 +19,6 @@ use crate::database::models::notification_item::NotificationBuilder; use crate::models::notifications::NotificationBody; use crate::models::pats::{PersonalAccessToken, Scopes}; use crate::queue::session::AuthQueue; -use crate::util::validate::validation_errors_to_string; use serde::Deserialize; use validator::Validate; @@ -54,7 +54,8 @@ pub async fn get_pats( &session_queue, Scopes::PAT_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let pat_ids = @@ -63,11 +64,13 @@ pub async fn get_pats( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching personal access tokens from database")?; let pats = database::models::pat_item::DBPersonalAccessToken::get_many_ids( &pat_ids, &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching personal access tokens from database")?; Ok(HttpResponse::Ok().json( pats.into_iter() @@ -104,19 +107,20 @@ pub async fn create_pat( redis: Data, session_queue: Data, ) -> Result { - info.0.validate().map_err(|err| { - ApiError::InvalidInput(validation_errors_to_string(err, None)) - })?; + info.0 + .validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; if info.scopes.is_restricted() { - return Err(ApiError::InvalidInput( - "Invalid scopes requested!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Invalid scopes requested!", + ))); } if info.expires < Utc::now() { - return Err(ApiError::InvalidInput( - "Expire date must be in the future!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Expire date must be in the future!", + ))); } let user = get_user_from_headers( @@ -126,12 +130,18 @@ pub async fn create_pat( &session_queue, Scopes::PAT_CREATE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; - let id = generate_pat_id(&mut transaction).await?; + let id = generate_pat_id(&mut transaction) + .await + .wrap_internal_err("generating pat ID")?; let token = ChaCha20Rng::from_entropy() .sample_iter(&Alphanumeric) @@ -152,7 +162,8 @@ pub async fn create_pat( last_used: None, } .insert(&mut transaction) - .await?; + .await + .wrap_internal_err("inserting database records for `create_pat`")?; NotificationBuilder { body: NotificationBody::PatCreated { @@ -160,14 +171,19 @@ pub async fn create_pat( }, } .insert(user.id.into(), &mut transaction, &redis) - .await?; - transaction.commit().await?; + .await + .wrap_internal_err("inserting database records for `create_pat`")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; database::models::pat_item::DBPersonalAccessToken::clear_cache( vec![(None, None, Some(user.id.into()))], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::Ok().json(PersonalAccessToken { id: id.into(), @@ -213,9 +229,10 @@ pub async fn edit_pat( redis: Data, session_queue: Data, ) -> Result { - info.0.validate().map_err(|err| { - ApiError::InvalidInput(validation_errors_to_string(err, None)) - })?; + info.0 + .validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; let user = get_user_from_headers( &req, @@ -224,25 +241,30 @@ pub async fn edit_pat( &session_queue, Scopes::PAT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let id = id.into_inner().0; let pat = database::models::pat_item::DBPersonalAccessToken::get( &id, &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching personal access token from database")?; if let Some(pat) = pat && pat.user_id == user.id.into() { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; if let Some(scopes) = &info.scopes { if scopes.is_restricted() { - return Err(ApiError::InvalidInput( - "Invalid scopes requested!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Invalid scopes requested!", + ))); } sqlx::query!( @@ -255,7 +277,8 @@ pub async fn edit_pat( pat.id.0 ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `edit_pat`")?; } if let Some(name) = &info.name { sqlx::query!( @@ -268,13 +291,14 @@ pub async fn edit_pat( pat.id.0 ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `edit_pat`")?; } if let Some(expires) = &info.expires { if expires < &Utc::now() { - return Err(ApiError::InvalidInput( - "Expire date must be in the future!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Expire date must be in the future!", + ))); } sqlx::query!( @@ -287,15 +311,20 @@ pub async fn edit_pat( pat.id.0 ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `edit_pat`")?; } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; database::models::pat_item::DBPersonalAccessToken::clear_cache( vec![(Some(pat.id), Some(pat.access_token), Some(pat.user_id))], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; } Ok(HttpResponse::NoContent().finish()) @@ -330,29 +359,39 @@ pub async fn delete_pat( &session_queue, Scopes::PAT_DELETE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let id = id.into_inner().0; let pat = database::models::pat_item::DBPersonalAccessToken::get( &id, &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching personal access token from database")?; if let Some(pat) = pat && pat.user_id == user.id.into() { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; database::models::pat_item::DBPersonalAccessToken::remove( pat.id, &mut transaction, ) - .await?; - transaction.commit().await?; + .await + .wrap_internal_err("deleting personal access token from database")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; database::models::pat_item::DBPersonalAccessToken::clear_cache( vec![(Some(pat.id), Some(pat.access_token), Some(pat.user_id))], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; } Ok(HttpResponse::NoContent().finish()) diff --git a/apps/labrinth/src/routes/internal/search.rs b/apps/labrinth/src/routes/internal/search.rs index 6521a21800..04063ebfcc 100644 --- a/apps/labrinth/src/routes/internal/search.rs +++ b/apps/labrinth/src/routes/internal/search.rs @@ -1,3 +1,4 @@ +use crate::util::error::Context as _; use crate::util::guards::admin_key_guard; use crate::{ routes::ApiError, @@ -19,7 +20,12 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { pub async fn tasks( search: web::Data, ) -> Result, ApiError> { - Ok(web::Json(search.tasks().await.map_err(ApiError::Internal)?)) + Ok(web::Json( + search + .tasks() + .await + .wrap_internal_err("fetching search tasks")?, + )) } /// Cancel search tasks. @@ -36,6 +42,6 @@ pub async fn tasks_cancel( search .tasks_cancel(&body) .await - .map_err(ApiError::Internal)?; + .wrap_internal_err("cancelling search tasks")?; Ok(()) } diff --git a/apps/labrinth/src/routes/internal/server_ping.rs b/apps/labrinth/src/routes/internal/server_ping.rs index f80961bc88..5847572ea5 100644 --- a/apps/labrinth/src/routes/internal/server_ping.rs +++ b/apps/labrinth/src/routes/internal/server_ping.rs @@ -44,7 +44,8 @@ pub async fn ping_minecraft_java( &session_queue, Scopes::SESSION_ACCESS, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let timeout = request.timeout_ms.map(Duration::from_millis); server_ping::ping_server(&request.address, timeout) diff --git a/apps/labrinth/src/routes/internal/session.rs b/apps/labrinth/src/routes/internal/session.rs index c6316a3bad..f7bd6f685c 100644 --- a/apps/labrinth/src/routes/internal/session.rs +++ b/apps/labrinth/src/routes/internal/session.rs @@ -9,6 +9,7 @@ use crate::models::pats::Scopes; use crate::models::sessions::Session; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::util::error::Context as _; use actix_web::http::header::AUTHORIZATION; use actix_web::web::Data; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; @@ -159,20 +160,24 @@ pub async fn list( &session_queue, Scopes::SESSION_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let session = req .headers() .get(AUTHORIZATION) .and_then(|x| x.to_str().ok()) - .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + .ok_or_else(|| AuthenticationError::InvalidCredentials) + .wrap_auth_err("authenticating API request")?; let session_ids = DBSession::get_user_sessions(current_user.id.into(), &**pool, &redis) - .await?; + .await + .wrap_internal_err("fetching sessions from database")?; let sessions = DBSession::get_many_ids(&session_ids, &**pool, &redis) - .await? + .await + .wrap_internal_err("fetching sessions from database")? .into_iter() .filter(|x| x.expires > Utc::now()) .map(|x| Session::from(x, false, Some(session))) @@ -211,17 +216,28 @@ pub async fn delete( &session_queue, Scopes::SESSION_DELETE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - let session = DBSession::get(info.into_inner().0, &**pool, &redis).await?; + let session = DBSession::get(info.into_inner().0, &**pool, &redis) + .await + .wrap_internal_err("fetching session from database")?; if let Some(session) = session && session.user_id == current_user.id.into() { - let mut transaction = pool.begin().await?; - DBSession::remove(session.id, &mut transaction).await?; - transaction.commit().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; + DBSession::remove(session.id, &mut transaction) + .await + .wrap_internal_err("deleting session from database")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; DBSession::clear_cache( vec![( Some(session.id), @@ -230,7 +246,8 @@ pub async fn delete( )], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; } Ok(HttpResponse::NoContent().body("")) @@ -258,16 +275,14 @@ pub async fn refresh( .headers() .get(AUTHORIZATION) .and_then(|x| x.to_str().ok()) - .ok_or_else(|| { - ApiError::Authentication(AuthenticationError::InvalidCredentials) - })?; + .wrap_auth_err_with(|| AuthenticationError::InvalidCredentials)?; // We should ensure that the authorization given is a session token, and not some other type of token (like a PAT), since this endpoint is only for refreshing sessions. // This is done by checking the prefix of the token, which should be "mra_" for session tokens. if !session.starts_with("mra_") { - return Err(ApiError::Authentication( + return Err(ApiError::Auth(eyre::eyre!( AuthenticationError::InvalidCredentials, - )); + ))); } let current_user = get_user_from_bearer_token( @@ -278,23 +293,31 @@ pub async fn refresh( &session_queue, true, // Allow expired sessions, since we want to allow refreshing expired sessions ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - let session = DBSession::get(session, &**pool, &redis).await?; + let session = DBSession::get(session, &**pool, &redis) + .await + .wrap_internal_err("fetching session from database")?; if let Some(session) = session { if current_user.id != session.user_id.into() || session.refresh_expires < Utc::now() { - return Err(ApiError::Authentication( + return Err(ApiError::Auth(eyre::eyre!( AuthenticationError::InvalidCredentials, - )); + ))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; - DBSession::remove(session.id, &mut transaction).await?; + DBSession::remove(session.id, &mut transaction) + .await + .wrap_internal_err("deleting session from database")?; let new_session = issue_session( req, session.user_id, @@ -302,8 +325,12 @@ pub async fn refresh( &redis, Some(session.refresh_expires), ) - .await?; - transaction.commit().await?; + .await + .wrap_auth_err("authenticating API request")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; DBSession::clear_cache( vec![( Some(session.id), @@ -312,12 +339,13 @@ pub async fn refresh( )], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::Ok().json(Session::from(new_session, true, None))) } else { - Err(ApiError::Authentication( + Err(ApiError::Auth(eyre::eyre!( AuthenticationError::InvalidCredentials, - )) + ))) } } diff --git a/apps/labrinth/src/routes/internal/statuses.rs b/apps/labrinth/src/routes/internal/statuses.rs index 160cdba9ba..62e4fde4d9 100644 --- a/apps/labrinth/src/routes/internal/statuses.rs +++ b/apps/labrinth/src/routes/internal/statuses.rs @@ -15,6 +15,7 @@ use crate::sync::friends::{FRIENDS_CHANNEL_NAME, RedisFriendsMessage}; use crate::sync::status::{ get_user_status, push_back_user_expiry, replace_user_status, }; +use crate::util::error::Context as _; use actix_web::web::{Data, Payload}; use actix_web::{HttpRequest, HttpResponse, get, web}; use actix_ws::Message; @@ -67,15 +68,14 @@ pub async fn ws_init( &session_queue, false, ) - .await? - .ok_or_else(|| { - ApiError::Authentication(AuthenticationError::InvalidCredentials) - })?; + .await + .wrap_auth_err("authenticating API request")? + .wrap_auth_err_with(|| AuthenticationError::InvalidCredentials)?; if !scopes.contains(Scopes::SESSION_ACCESS) { - return Err(ApiError::Authentication( + return Err(ApiError::Auth(eyre::eyre!( AuthenticationError::InvalidCredentials, - )); + ))); } let user = User::from_full(db_user); @@ -93,7 +93,9 @@ pub async fn ws_init( }; let friends = - DBFriend::get_user_friends(user.id.into(), Some(true), &**pool).await?; + DBFriend::get_user_friends(user.id.into(), Some(true), &**pool) + .await + .wrap_internal_err("fetching friends from database")?; let friend_statuses = if !friends.is_empty() { let db = db.clone(); @@ -127,11 +129,12 @@ pub async fn ws_init( }; let _ = session - .text(serde_json::to_string( - &ServerToClientMessage::FriendStatuses { + .text( + serde_json::to_string(&ServerToClientMessage::FriendStatuses { statuses: friend_statuses, - }, - )?) + }) + .wrap_request_err("serializing friend statuses")?, + ) .await; let unread_launcher_invites = @@ -140,7 +143,8 @@ pub async fn ws_init( &**pool, &redis, ) - .await? + .await + .wrap_internal_err("fetching notifications from database")? .into_iter() .filter(|notification| { !notification.read @@ -153,7 +157,12 @@ pub async fn ws_init( .map(Notification::from); for notification in unread_launcher_invites { - let _ = session.text(serde_json::to_string(¬ification)?).await; + let _ = session + .text( + serde_json::to_string(¬ification) + .wrap_request_err("serializing launcher notification")?, + ) + .await; } let db = db.clone(); @@ -168,12 +177,15 @@ pub async fn ws_init( #[cfg(debug_assertions)] tracing::info!("Connection {socket_id} opened by {}", user.id); - replace_user_status(None, Some(&status), &redis).await?; + replace_user_status(None, Some(&status), &redis) + .await + .wrap_internal_err("reading HTTP response body")?; broadcast_friends_message( &redis, RedisFriendsMessage::StatusUpdate { status }, ) - .await?; + .await + .wrap_internal_err("reading HTTP response body")?; let (shutdown_sender, mut shutdown_receiver) = tokio::sync::oneshot::channel::<()>(); diff --git a/apps/labrinth/src/routes/maven.rs b/apps/labrinth/src/routes/maven.rs index bc351ccb03..a10e7d724c 100644 --- a/apps/labrinth/src/routes/maven.rs +++ b/apps/labrinth/src/routes/maven.rs @@ -11,6 +11,8 @@ use crate::models::pats::Scopes; use crate::models::projects::FileType; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::util::error::ApiContext as _; +use crate::util::error::Context; use crate::{auth::get_user_from_headers, database}; use actix_web::{HttpRequest, HttpResponse, get, route, web}; use quick_xml::escape::escape; @@ -85,9 +87,11 @@ pub async fn maven_metadata( ) -> Result { let project_id = params.into_inner().0; let Some(project) = - database::models::DBProject::get(&project_id, &**pool, &redis).await? + database::models::DBProject::get(&project_id, &**pool, &redis) + .await + .wrap_api_err("fetching Maven project")? else { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; let user_option = get_user_from_headers( @@ -101,8 +105,11 @@ pub async fn maven_metadata( .map(|x| x.1) .ok(); - if !is_visible_project(&project.inner, &user_option, &pool, false).await? { - return Err(ApiError::NotFound); + if !is_visible_project(&project.inner, &user_option, &pool, false) + .await + .wrap_api_err("checking project visibility")? + { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } let version_names = sqlx::query!( @@ -119,7 +126,8 @@ pub async fn maven_metadata( .collect::>(), ) .fetch_all(&**pool) - .await?; + .await + .wrap_internal_err("fetching version names from database")?; let mut new_versions = Vec::new(); let mut vals = HashSet::new(); @@ -162,9 +170,11 @@ pub async fn maven_metadata( }, }; - Ok(HttpResponse::Ok() - .content_type("text/xml") - .body(yaserde::ser::to_string(&respdata).map_err(ApiError::Xml)?)) + Ok(HttpResponse::Ok().content_type("text/xml").body( + yaserde::ser::to_string(&respdata) + .map_err(eyre::Report::msg) + .wrap_internal_err("serializing Maven metadata as XML")?, + )) } async fn find_version( @@ -179,7 +189,8 @@ async fn find_version( let all_versions = database::models::DBVersion::get_many(&project.versions, pool, redis) - .await?; + .await + .wrap_internal_err("fetching versions from database")?; let exact_matches = all_versions .iter() @@ -199,7 +210,8 @@ async fn find_version( }; let db_loaders: HashSet = Loader::list(pool, redis) - .await? + .await + .wrap_internal_err("fetching loader from Redis")? .into_iter() .map(|x| x.loader) .collect(); @@ -310,9 +322,11 @@ pub async fn version_file( ) -> Result { let (project_id, vnum, file) = params.into_inner(); let Some(project) = - database::models::DBProject::get(&project_id, &**pool, &redis).await? + database::models::DBProject::get(&project_id, &**pool, &redis) + .await + .wrap_api_err("fetching Maven project")? else { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; let user_option = get_user_from_headers( @@ -326,17 +340,25 @@ pub async fn version_file( .map(|x| x.1) .ok(); - if !is_visible_project(&project.inner, &user_option, &pool, false).await? { - return Err(ApiError::NotFound); + if !is_visible_project(&project.inner, &user_option, &pool, false) + .await + .wrap_api_err("checking project visibility")? + { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } - let Some(version) = find_version(&project, &vnum, &pool, &redis).await? + let Some(version) = find_version(&project, &vnum, &pool, &redis) + .await + .wrap_api_err("fetching Maven version")? else { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; - if !is_visible_version(&version.inner, &user_option, &pool, &redis).await? { - return Err(ApiError::NotFound); + if !is_visible_version(&version.inner, &user_option, &pool, &redis) + .await + .wrap_api_err("checking version visibility")? + { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } if file.eq_ignore_ascii_case(&format!("{}-{}.pom", &project_id, &vnum)) { @@ -352,9 +374,11 @@ pub async fn version_file( name: project.inner.name, description: escape(project.inner.summary).into_owned(), }; - 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( + yaserde::ser::to_string(&respdata) + .map_err(eyre::Report::msg) + .wrap_internal_err("serializing Maven project as XML")?, + )); } else if let Some(selected_file) = find_file(&project_id, &vnum, &version, &file) { @@ -363,7 +387,7 @@ pub async fn version_file( .body("")); } - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } #[utoipa::path( @@ -385,9 +409,11 @@ pub async fn version_file_sha1( ) -> Result { let (project_id, vnum, file) = params.into_inner(); let Some(project) = - database::models::DBProject::get(&project_id, &**pool, &redis).await? + database::models::DBProject::get(&project_id, &**pool, &redis) + .await + .wrap_api_err("fetching Maven project")? else { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; let user_option = get_user_from_headers( @@ -401,17 +427,25 @@ pub async fn version_file_sha1( .map(|x| x.1) .ok(); - if !is_visible_project(&project.inner, &user_option, &pool, false).await? { - return Err(ApiError::NotFound); + if !is_visible_project(&project.inner, &user_option, &pool, false) + .await + .wrap_api_err("checking project visibility")? + { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } - let Some(version) = find_version(&project, &vnum, &pool, &redis).await? + let Some(version) = find_version(&project, &vnum, &pool, &redis) + .await + .wrap_api_err("fetching Maven version")? else { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; - if !is_visible_version(&version.inner, &user_option, &pool, &redis).await? { - return Err(ApiError::NotFound); + if !is_visible_version(&version.inner, &user_option, &pool, &redis) + .await + .wrap_api_err("checking version visibility")? + { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } Ok(find_file(&project_id, &vnum, &version, &file) @@ -441,9 +475,11 @@ pub async fn version_file_sha512( ) -> Result { let (project_id, vnum, file) = params.into_inner(); let Some(project) = - database::models::DBProject::get(&project_id, &**pool, &redis).await? + database::models::DBProject::get(&project_id, &**pool, &redis) + .await + .wrap_api_err("fetching Maven project")? else { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; let user_option = get_user_from_headers( @@ -457,17 +493,25 @@ pub async fn version_file_sha512( .map(|x| x.1) .ok(); - if !is_visible_project(&project.inner, &user_option, &pool, false).await? { - return Err(ApiError::NotFound); + if !is_visible_project(&project.inner, &user_option, &pool, false) + .await + .wrap_api_err("checking project visibility")? + { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } - let Some(version) = find_version(&project, &vnum, &pool, &redis).await? + let Some(version) = find_version(&project, &vnum, &pool, &redis) + .await + .wrap_api_err("fetching Maven version")? else { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; - if !is_visible_version(&version.inner, &user_option, &pool, &redis).await? { - return Err(ApiError::NotFound); + if !is_visible_version(&version.inner, &user_option, &pool, &redis) + .await + .wrap_api_err("checking version visibility")? + { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } Ok(find_file(&project_id, &vnum, &version, &file) diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index fc75849d22..fc5f72e75c 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -1,13 +1,10 @@ -use crate::database::models::DelphiReportIssueDetailsId; use crate::env::ENV; -use crate::file_hosting::FileHostingError; use crate::util::cors::default_cors; use actix_cors::Cors; use actix_files::Files; use actix_web::http::StatusCode; use actix_web::{HttpResponse, web}; use futures::FutureExt; -use serde_json::json; use utoipa::openapi::extensions::ExtensionsBuilder; use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; @@ -192,90 +189,49 @@ pub enum ApiError { /// Caller attempted a request which they are not allowed to make. #[error(transparent)] Auth(eyre::Report), - #[error("Invalid input: {0}")] - InvalidInput(String), - #[error("Error while uploading file: {0}")] - FileHosting(#[from] FileHostingError), - #[error("database error")] - Database(#[from] crate::database::models::DatabaseError), - // todo: remove this variant - #[error("Postgres database error: {0}")] - SqlxDatabase(#[from] sqlx::Error), - #[error("redis database error")] - RedisDatabase(#[from] redis::RedisError), - #[error("Clickhouse error: {0}")] - Clickhouse(#[from] clickhouse::error::Error), - #[error("XML error: {0}")] - Xml(String), - #[error("Deserialization error: {0}")] - Json(#[from] serde_json::Error), - #[error("Authentication error: {0}")] - Authentication(#[from] crate::auth::AuthenticationError), - #[error("Authentication error: {0}")] - CustomAuthentication(String), - #[error("Error while validating input: {0}")] - Validation(String), - #[error("Search error: {0}")] - Search(#[from] meilisearch_sdk::errors::Error), - #[error("Payments error: {0}")] - Payments(String), - #[error("Discord error: {0}")] - Discord(String), - #[error("Slack webhook error: {0}")] - Slack(String), - #[error("Captcha error. Try resubmitting the form.")] - Turnstile, - #[error("Error while decoding Base62: {0}")] - Decoding(#[from] ariadne::ids::DecodingError), - #[error("Image parsing error: {0}")] - ImageParse(#[from] image::ImageError), - #[error("Password hashing error: {0}")] - PasswordHashing(#[from] argon2::password_hash::Error), - #[error("{0}")] - Mail(#[from] crate::queue::email::MailError), - #[error("Error while rerouting request: {0:?}")] - Reroute(#[from] reqwest::Error), - #[error("Unable to read zip archive: {0}")] - Zip(#[from] zip::result::ZipError), - #[error("IO Error: {0}")] - Io(#[from] std::io::Error), - #[error("Resource not found")] - NotFound, - #[error("Conflict: {0}")] - Conflict(String), - #[error("precondition required: {0}")] - PreconditionRequired(String), - #[error("precondition failed: {0}")] - PreconditionFailed(String), - #[error("External tax compliance API error")] - TaxComplianceApi, + /// The requested resource does not exist. #[error(transparent)] - TaxProcessor(#[from] crate::util::anrok::AnrokError), - #[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), - #[error("Error while interacting with Delphi: {0:?}")] - Delphi(eyre::Error), + NotFound(eyre::Report), + /// The request conflicts with the current state of the resource. #[error(transparent)] - Mural(#[from] Box), - #[error("report still has {} issue details with no verdict", details.len())] - TechReviewDetailsWithNoVerdict { - details: Vec, - }, -} - -impl From for ApiError { - fn from(error: xredis::Error) -> Self { - Self::Database(error.into()) - } + Conflict(eyre::Report), + /// A service dependency failed to complete the request. + #[error(transparent)] + FailedDependency(eyre::Report), + /// The request requires a precondition. + #[error(transparent)] + PreconditionRequired(eyre::Report), + /// A request precondition was not met. + #[error(transparent)] + PreconditionFailed(eyre::Report), + /// The caller exceeded a request rate limit. + #[error(transparent)] + RateLimit(eyre::Report), } impl ApiError { - pub fn delphi(err: impl Into) -> Self { - Self::Delphi(err.into()) + /// Adds context to the contained report while preserving the HTTP status. + pub(crate) fn wrap_err(self, msg: D) -> Self + where + D: Send + Sync + std::fmt::Debug + std::fmt::Display + 'static, + { + match self { + Self::Internal(report) => Self::Internal(report.wrap_err(msg)), + Self::Request(report) => Self::Request(report.wrap_err(msg)), + Self::Auth(report) => Self::Auth(report.wrap_err(msg)), + Self::NotFound(report) => Self::NotFound(report.wrap_err(msg)), + Self::Conflict(report) => Self::Conflict(report.wrap_err(msg)), + Self::FailedDependency(report) => { + Self::FailedDependency(report.wrap_err(msg)) + } + Self::PreconditionRequired(report) => { + Self::PreconditionRequired(report.wrap_err(msg)) + } + Self::PreconditionFailed(report) => { + Self::PreconditionFailed(report.wrap_err(msg)) + } + Self::RateLimit(report) => Self::RateLimit(report.wrap_err(msg)), + } } pub fn as_api_error<'a>(&self) -> crate::models::error::ApiError<'a> { @@ -284,60 +240,15 @@ impl ApiError { Self::Internal(..) => "internal_error", Self::Request(..) => "request_error", Self::Auth(..) => "auth_error", - Self::Database(..) => "database_error", - Self::SqlxDatabase(..) => "database_error", - Self::RedisDatabase(..) => "database_error", - Self::Authentication(..) => "unauthorized", - Self::CustomAuthentication(..) => "unauthorized", - Self::Xml(..) => "xml_error", - Self::Json(..) => "json_error", - Self::Search(..) => "search_error", - Self::FileHosting(..) => "file_hosting_error", - Self::InvalidInput(..) => "invalid_input", - Self::Validation(..) => "invalid_input", - Self::Payments(..) => "payments_error", - Self::Discord(..) => "discord_error", - Self::Turnstile => "turnstile_error", - Self::Decoding(..) => "decoding_error", - Self::ImageParse(..) => "invalid_image", - Self::PasswordHashing(..) => "password_hashing_error", - Self::Mail(..) => "mail_error", - Self::Clickhouse(..) => "clickhouse_error", - Self::Reroute(..) => "reroute_error", - Self::NotFound => "not_found", + Self::NotFound(..) => "not_found", Self::Conflict(..) => "conflict", + Self::FailedDependency(..) => "failed_dependency", Self::PreconditionRequired(..) => "precondition_required", Self::PreconditionFailed(..) => "precondition_failed", - Self::TaxComplianceApi => "tax_compliance_api_error", - Self::Zip(..) => "zip_error", - Self::Io(..) => "io_error", - Self::RateLimitError(..) => "ratelimit_error", - Self::Stripe(..) => "stripe_error", - Self::TaxProcessor(..) => "tax_processor_error", - Self::Slack(..) => "slack_error", - Self::Delphi(..) => "delphi_error", - Self::Mural(..) => "mural_error", - Self::TechReviewDetailsWithNoVerdict { .. } => { - "tech_review_issues_with_no_verdict" - } - }, - description: match self { - Self::Internal(e) => format!("{e:#}"), - Self::Request(e) => format!("{e:#}"), - Self::Auth(e) => format!("{e:#}"), - _ => self.to_string(), - }, - details: match self { - Self::Mural(err) => serde_json::to_value(err.clone()).ok(), - Self::TechReviewDetailsWithNoVerdict { details } => { - let details = serde_json::to_value(details) - .expect("details should never fail to serialize"); - Some(json!({ - "issue_details": details - })) - } - _ => None, + Self::RateLimit(..) => "ratelimit_error", }, + description: format!("{self:#}"), + details: None, } } } @@ -348,42 +259,12 @@ impl actix_web::ResponseError for ApiError { Self::Internal(..) => StatusCode::INTERNAL_SERVER_ERROR, Self::Request(..) => StatusCode::BAD_REQUEST, Self::Auth(..) => StatusCode::UNAUTHORIZED, - Self::InvalidInput(..) => StatusCode::BAD_REQUEST, - Self::Database(..) => StatusCode::INTERNAL_SERVER_ERROR, - Self::SqlxDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR, - Self::RedisDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR, - Self::Clickhouse(..) => StatusCode::INTERNAL_SERVER_ERROR, - Self::Authentication(..) => StatusCode::UNAUTHORIZED, - Self::CustomAuthentication(..) => StatusCode::UNAUTHORIZED, - Self::Xml(..) => StatusCode::INTERNAL_SERVER_ERROR, - Self::Json(..) => StatusCode::BAD_REQUEST, - Self::Search(..) => StatusCode::INTERNAL_SERVER_ERROR, - Self::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR, - Self::Validation(..) => StatusCode::BAD_REQUEST, - Self::Payments(..) => StatusCode::FAILED_DEPENDENCY, - Self::Discord(..) => StatusCode::FAILED_DEPENDENCY, - Self::Turnstile => StatusCode::BAD_REQUEST, - Self::Decoding(..) => StatusCode::BAD_REQUEST, - Self::ImageParse(..) => StatusCode::BAD_REQUEST, - Self::PasswordHashing(..) => StatusCode::INTERNAL_SERVER_ERROR, - Self::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR, - Self::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR, - Self::NotFound => StatusCode::NOT_FOUND, + Self::NotFound(..) => StatusCode::NOT_FOUND, Self::Conflict(..) => StatusCode::CONFLICT, + Self::FailedDependency(..) => StatusCode::FAILED_DEPENDENCY, Self::PreconditionRequired(..) => StatusCode::PRECONDITION_REQUIRED, Self::PreconditionFailed(..) => StatusCode::PRECONDITION_FAILED, - Self::TaxComplianceApi => StatusCode::INTERNAL_SERVER_ERROR, - Self::Zip(..) => StatusCode::BAD_REQUEST, - Self::Io(..) => StatusCode::BAD_REQUEST, - Self::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS, - Self::Stripe(..) => StatusCode::FAILED_DEPENDENCY, - Self::TaxProcessor(..) => StatusCode::INTERNAL_SERVER_ERROR, - Self::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR, - Self::Delphi(..) => StatusCode::INTERNAL_SERVER_ERROR, - Self::Mural(..) => StatusCode::BAD_REQUEST, - Self::TechReviewDetailsWithNoVerdict { .. } => { - StatusCode::BAD_REQUEST - } + Self::RateLimit(..) => StatusCode::TOO_MANY_REQUESTS, } } diff --git a/apps/labrinth/src/routes/updates.rs b/apps/labrinth/src/routes/updates.rs index 7b287c6cf9..95db80199d 100644 --- a/apps/labrinth/src/routes/updates.rs +++ b/apps/labrinth/src/routes/updates.rs @@ -1,3 +1,5 @@ +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use std::cmp::Reverse; use std::collections::HashMap; @@ -55,8 +57,9 @@ pub async fn forge_updates( let (id,) = info.into_inner(); let project = database::models::DBProject::get(&id, &**pool, &redis) - .await? - .ok_or_else(|| ApiError::InvalidInput(ERROR.to_string()))?; + .await + .wrap_api_err("fetching project from database")? + .wrap_request_err_with(|| ERROR.to_string())?; let user_option = get_user_from_headers( &req, @@ -69,8 +72,11 @@ pub async fn forge_updates( .map(|x| x.1) .ok(); - if !is_visible_project(&project.inner, &user_option, &pool, false).await? { - return Err(ApiError::InvalidInput(ERROR.to_string())); + if !is_visible_project(&project.inner, &user_option, &pool, false) + .await + .wrap_api_err("checking project visibility")? + { + return Err(ApiError::Request(eyre::eyre!("{ERROR}"))); } let versions = database::models::DBVersion::get_many( @@ -78,7 +84,8 @@ pub async fn forge_updates( &***ro_pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching versions from database")?; let loaders = match &*neo.neoforge { "only" => |x: &String| *x == "neoforge", @@ -96,7 +103,8 @@ pub async fn forge_updates( &ro_pool, &redis, ) - .await?; + .await + .wrap_api_err("fetching compatible update versions")?; versions.sort_by_key(|b| Reverse(b.date_published)); diff --git a/apps/labrinth/src/routes/v2/notifications.rs b/apps/labrinth/src/routes/v2/notifications.rs index f84f58ab5d..52f279fa6c 100644 --- a/apps/labrinth/src/routes/v2/notifications.rs +++ b/apps/labrinth/src/routes/v2/notifications.rs @@ -6,6 +6,7 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::routes::v2_reroute; use crate::routes::v3; +use crate::util::error::ApiContext as _; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use serde::{Deserialize, Serialize}; use xredis::RedisPool; @@ -65,7 +66,11 @@ pub async fn notifications_get( ) .await .or_else(v2_reroute::flatten_404_error); - match v2_reroute::extract_ok_json::>(resp?).await { + match v2_reroute::extract_ok_json::>( + resp.wrap_api_err("extracting v2 response body")?, + ) + .await + { Ok(notifications) => { let notifications: Vec = notifications .into_iter() @@ -115,7 +120,8 @@ pub async fn notification_get( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; match v2_reroute::extract_ok_json::(response).await { Ok(notification) => { let notification = LegacyNotification::from(notification); diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs index d36bae318f..407d6359b2 100644 --- a/apps/labrinth/src/routes/v2/projects.rs +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -13,6 +13,8 @@ use crate::queue::session::AuthQueue; use crate::routes::v3::projects::ProjectIds; use crate::routes::{ApiError, v2_reroute, v3}; use crate::search::{SearchBackend, SearchRequest, SearchState}; +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -79,7 +81,8 @@ pub async fn project_search( // While the backend for this has changed, it doesnt affect much // in the API calls except that 'versions:x' is now 'game_versions:x' let facets: Option>> = if let Some(facets) = info.facets { - let facets = serde_json::from_str::>>(&facets)?; + let facets = serde_json::from_str::>>(&facets) + .wrap_request_err("deserializing JSON data")?; Some( facets @@ -119,7 +122,10 @@ pub async fn project_search( ..info }; - let results = search_backend.search_for_project(&info, &redis).await?; + let results = search_backend + .search_for_project(&info, &redis) + .await + .wrap_api_err("searching projects")?; let results = LegacySearchResults::from(results); @@ -194,12 +200,17 @@ pub async fn random_projects_get( ) .await .or_else(v2_reroute::flatten_404_error) - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::>(response).await { Ok(project) => { let legacy_projects = - LegacyProject::from_many(project, &**pool, &redis).await?; + LegacyProject::from_many(project, &**pool, &redis) + .await + .wrap_internal_err( + "executing `LegacyProject::from_many`", + )?; Ok(HttpResponse::Ok().json(legacy_projects)) } Err(response) => Ok(response), @@ -234,13 +245,18 @@ pub async fn projects_get( ) .await .or_else(v2_reroute::flatten_404_error) - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::>(response).await { Ok(project) => { let legacy_projects = - LegacyProject::from_many(project, &**pool, &redis).await?; + LegacyProject::from_many(project, &**pool, &redis) + .await + .wrap_internal_err( + "executing `LegacyProject::from_many`", + )?; Ok(HttpResponse::Ok().json(legacy_projects)) } Err(response) => Ok(response), @@ -284,14 +300,18 @@ pub async fn project_get( .await { Ok(resp) => resp.0, - Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")), + Err(ApiError::NotFound(_)) => { + return Ok(HttpResponse::NotFound().body("")); + } Err(err) => return Err(err), }; // Convert response to V2 format let version_item = match project.versions.first() { Some(vid) => { - version_item::DBVersion::get((*vid).into(), &**pool, &redis).await? + version_item::DBVersion::get((*vid).into(), &**pool, &redis) + .await + .wrap_internal_err("fetching version from database")? } None => None, }; @@ -371,7 +391,8 @@ pub async fn dependency_list( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; match v2_reroute::extract_ok_json::< crate::routes::v3::projects::DependencyInfo, @@ -384,7 +405,10 @@ pub async fn dependency_list( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err( + "converting dependency projects to legacy responses", + )?; let converted_versions = dependency_info .versions .into_iter() @@ -585,7 +609,9 @@ pub async fn project_edit( if let Some(donation_urls) = v2_new_project.donation_urls { // Fetch current donation links from project so we know what to delete let fetched_example_project = - project_item::DBProject::get(&info.0, &**pool, &redis).await?; + project_item::DBProject::get(&info.0, &**pool, &redis) + .await + .wrap_api_err("fetching project from database")?; let donation_links = fetched_example_project .map(|x| { x.urls @@ -647,7 +673,8 @@ pub async fn project_edit( search_state.clone(), ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // If client and server side were set, we will call // the version setting route for each version to set the side types for each of them. @@ -659,11 +686,13 @@ pub async fn project_edit( &**pool, &redis, ) - .await?; + .await + .wrap_api_err("fetching project from database")?; let version_ids = project_item.map(|x| x.versions).unwrap_or_default(); let versions = version_item::DBVersion::get_many(&version_ids, &**pool, &redis) - .await?; + .await + .wrap_internal_err("fetching versions from database")?; for version in versions { let version = Version::from(version); let mut fields = version.fields; @@ -690,7 +719,8 @@ pub async fn project_edit( session_queue.clone(), search_state.clone(), ) - .await?; + .await + .wrap_api_err("editing project")?; } } Ok(response) @@ -795,7 +825,9 @@ pub async fn projects_edit( // If we are *setting* donation links, we will set every possible donation link to None, as // setting will delete all of them then 're-add' the ones we want to keep if let Some(donation_url) = bulk_edit_project.donation_urls { - let link_platforms = LinkPlatform::list(&**pool, &redis).await?; + let link_platforms = LinkPlatform::list(&**pool, &redis) + .await + .wrap_internal_err("fetching link platform from Redis")?; for link in link_platforms { if link.donation { link_urls.insert(link.name, None); diff --git a/apps/labrinth/src/routes/v2/reports.rs b/apps/labrinth/src/routes/v2/reports.rs index c50950e544..041900e5f7 100644 --- a/apps/labrinth/src/routes/v2/reports.rs +++ b/apps/labrinth/src/routes/v2/reports.rs @@ -3,6 +3,7 @@ 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::util::error::ApiContext as _; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use serde::Deserialize; use validator::Validate; @@ -44,7 +45,8 @@ pub async fn report_create( let response = v3::reports::report_create(req, pool, body, redis, session_queue) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::(response).await { @@ -109,7 +111,8 @@ pub async fn reports( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::>(response).await { @@ -164,7 +167,8 @@ pub async fn reports_get( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::>(response).await { @@ -209,7 +213,8 @@ pub async fn report_get( let response = v3::reports::report_get(req, pool, redis, info, session_queue) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::(response).await { diff --git a/apps/labrinth/src/routes/v2/statistics.rs b/apps/labrinth/src/routes/v2/statistics.rs index 94ac6f66e9..fed3166c32 100644 --- a/apps/labrinth/src/routes/v2/statistics.rs +++ b/apps/labrinth/src/routes/v2/statistics.rs @@ -3,6 +3,7 @@ use crate::routes::{ ApiError, v2_reroute, v3::{self, statistics::V3Stats}, }; +use crate::util::error::ApiContext as _; use actix_web::{HttpResponse, get, web}; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { @@ -36,7 +37,8 @@ pub async fn get_stats( ) -> Result { let response = v3::statistics::get_stats(pool) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; match v2_reroute::extract_ok_json::(response).await { Ok(stats) => { diff --git a/apps/labrinth/src/routes/v2/tags.rs b/apps/labrinth/src/routes/v2/tags.rs index 6f3ef45283..5d44f7b77e 100644 --- a/apps/labrinth/src/routes/v2/tags.rs +++ b/apps/labrinth/src/routes/v2/tags.rs @@ -1,3 +1,4 @@ +use crate::util::error::ApiContext as _; use std::collections::HashMap; use super::ApiError; @@ -54,7 +55,9 @@ pub async fn category_list( pool: web::Data, redis: web::Data, ) -> Result { - let response = v3::tags::category_list(pool, redis).await?; + let response = v3::tags::category_list(pool, redis) + .await + .wrap_api_err("executing `tags::category_list`")?; // Convert to V2 format match v2_reroute::extract_ok_json::>(response) @@ -102,7 +105,9 @@ pub async fn loader_list( pool: web::Data, redis: web::Data, ) -> Result { - let response = v3::tags::loader_list(pool, redis).await?; + let response = v3::tags::loader_list(pool, redis) + .await + .wrap_api_err("executing `tags::loader_list`")?; // Convert to V2 format match v2_reroute::extract_ok_json::>(response) @@ -198,7 +203,8 @@ pub async fn game_version_list( }), redis, ) - .await?; + .await + .wrap_api_err("fetching game versions")?; // Convert to V2 format Ok( @@ -302,7 +308,8 @@ pub async fn license_text( ) -> Result { let license = v3::tags::license_text(params) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert to V2 format Ok( @@ -348,7 +355,9 @@ pub async fn donation_platform_list( pool: web::Data, redis: web::Data, ) -> Result { - let response = v3::tags::link_platform_list(pool, redis).await?; + let response = v3::tags::link_platform_list(pool, redis) + .await + .wrap_api_err("executing `tags::link_platform_list`")?; // Convert to V2 format Ok( diff --git a/apps/labrinth/src/routes/v2/teams.rs b/apps/labrinth/src/routes/v2/teams.rs index 36c373094e..cdd4e30665 100644 --- a/apps/labrinth/src/routes/v2/teams.rs +++ b/apps/labrinth/src/routes/v2/teams.rs @@ -6,6 +6,7 @@ use crate::models::teams::{ use crate::models::v2::teams::LegacyTeamMember; use crate::queue::session::AuthQueue; use crate::routes::{ApiError, v2_reroute, v3}; +use crate::util::error::ApiContext as _; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use ariadne::ids::UserId; use rust_decimal::Decimal; @@ -63,7 +64,8 @@ pub async fn team_members_get_project( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::>(response).await { Ok(members) => { @@ -101,7 +103,8 @@ pub async fn team_members_get( let response = v3::teams::team_members_get(req, info, pool, redis, session_queue) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::>(response).await { Ok(members) => { @@ -148,7 +151,11 @@ pub async fn teams_get( .await .or_else(v2_reroute::flatten_404_error); // Convert response to V2 format - match v2_reroute::extract_ok_json::>>(response?).await { + match v2_reroute::extract_ok_json::>>( + response.wrap_api_err("extracting v2 response body")?, + ) + .await + { Ok(members) => { let members = members .into_iter() diff --git a/apps/labrinth/src/routes/v2/threads.rs b/apps/labrinth/src/routes/v2/threads.rs index 3854d4977a..e110ef5b98 100644 --- a/apps/labrinth/src/routes/v2/threads.rs +++ b/apps/labrinth/src/routes/v2/threads.rs @@ -5,6 +5,7 @@ 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::util::error::ApiContext as _; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use serde::Deserialize; use xredis::RedisPool; @@ -88,7 +89,8 @@ pub async fn threads_get( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::>(response).await { diff --git a/apps/labrinth/src/routes/v2/users.rs b/apps/labrinth/src/routes/v2/users.rs index 5879315336..3c5dae8e8e 100644 --- a/apps/labrinth/src/routes/v2/users.rs +++ b/apps/labrinth/src/routes/v2/users.rs @@ -8,6 +8,8 @@ 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::util::error::ApiContext as _; +use crate::util::error::Context as _; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use serde::{Deserialize, Serialize}; use validator::Validate; @@ -53,7 +55,8 @@ pub async fn user_auth_get( ) -> Result { let response = v3::users::user_auth_get(req, pool, redis, session_queue) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::(response).await { @@ -96,7 +99,8 @@ pub async fn users_get( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::>(response).await { @@ -136,7 +140,8 @@ pub async fn user_get( ) -> Result { let response = v3::users::user_get(req, info, pool, redis, session_queue) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::(response).await { @@ -181,13 +186,18 @@ pub async fn projects_list( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert to V2 projects match v2_reroute::extract_ok_json::>(response).await { Ok(project) => { let legacy_projects = - LegacyProject::from_many(project, &**pool, &redis).await?; + LegacyProject::from_many(project, &**pool, &redis) + .await + .wrap_internal_err( + "executing `LegacyProject::from_many`", + )?; Ok(HttpResponse::Ok().json(legacy_projects)) } Err(response) => Ok(response), @@ -448,13 +458,18 @@ pub async fn user_follows( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert to V2 projects match v2_reroute::extract_ok_json::>(response).await { Ok(project) => { let legacy_projects = - LegacyProject::from_many(project, &**pool, &redis).await?; + LegacyProject::from_many(project, &**pool, &redis) + .await + .wrap_internal_err( + "executing `LegacyProject::from_many`", + )?; Ok(HttpResponse::Ok().json(legacy_projects)) } Err(response) => Ok(response), @@ -494,7 +509,8 @@ pub async fn user_notifications( let response = v3::users::user_notifications(req, info, pool, redis, session_queue) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::>(response).await { Ok(notifications) => { diff --git a/apps/labrinth/src/routes/v2/version_file.rs b/apps/labrinth/src/routes/v2/version_file.rs index a4c529b8c1..f1db94c0bf 100644 --- a/apps/labrinth/src/routes/v2/version_file.rs +++ b/apps/labrinth/src/routes/v2/version_file.rs @@ -7,6 +7,8 @@ use crate::queue::session::AuthQueue; use crate::routes::HashAlgorithm; use crate::routes::v3::version_file::{DownloadRedirect, HashQuery}; use crate::routes::{FileHash, v2_reroute, v3}; +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -69,7 +71,8 @@ pub async fn get_version_from_hash( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::(response).await { @@ -233,7 +236,8 @@ pub async fn get_update_from_hash( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::(response).await { @@ -289,7 +293,8 @@ pub async fn get_versions_from_hashes( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert to V2 match v2_reroute::extract_ok_json::>(response) @@ -342,7 +347,8 @@ pub async fn get_projects_from_hashes( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert to V2 match v2_reroute::extract_ok_json::>(response) @@ -361,7 +367,8 @@ pub async fn get_projects_from_hashes( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("converting projects to legacy responses")?; let legacy_projects_hashes = hash_to_project_id .into_iter() .filter_map(|(hash, project_id)| { @@ -426,7 +433,9 @@ pub async fn update_files( .await { Ok(resp) => resp, - Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")), + Err(ApiError::NotFound(_)) => { + return Ok(HttpResponse::NotFound().body("")); + } Err(err) => return Err(err), }; @@ -477,7 +486,9 @@ pub async fn update_files_many( .await { Ok(resp) => resp, - Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")), + Err(ApiError::NotFound(_)) => { + return Ok(HttpResponse::NotFound().body("")); + } Err(err) => return Err(err), }; @@ -565,7 +576,8 @@ pub async fn update_individual_files( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::>(response) diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs index 2ffb4abd93..ec94f169ba 100644 --- a/apps/labrinth/src/routes/v2/versions.rs +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -1,3 +1,5 @@ +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use std::collections::HashMap; use super::ApiError; @@ -138,7 +140,8 @@ pub async fn version_list( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::>(response).await { @@ -191,7 +194,8 @@ pub async fn version_project_get( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::(response).await { Ok(version) => { @@ -242,7 +246,8 @@ pub async fn versions_get( session_queue, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::>(response).await { @@ -294,7 +299,8 @@ pub async fn version_get( ) .await .map(|b| HttpResponse::Ok().json(b)) - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("flattening v2 not-found response")?; // Convert response to V2 format match v2_reroute::extract_ok_json::(response).await { Ok(version) => { @@ -396,7 +402,9 @@ pub async fn version_edit( .await { Ok(resp) => resp, - Err(ApiError::NotFound) => return Ok(HttpResponse::NotFound().body("")), + Err(ApiError::NotFound(_)) => { + return Ok(HttpResponse::NotFound().body("")); + } Err(err) => return Err(err), }; let old_version = match v2_reroute::extract_ok_json::( @@ -450,12 +458,16 @@ pub async fn version_edit( info, pool, redis, - web::Json(serde_json::to_value(new_version)?), + web::Json( + serde_json::to_value(new_version) + .wrap_request_err("serializing version edit")?, + ), session_queue, search_state, ) .await - .or_else(v2_reroute::flatten_404_error)?; + .or_else(v2_reroute::flatten_404_error) + .wrap_api_err("editing version through v3 route")?; Ok(response) } diff --git a/apps/labrinth/src/routes/v2_reroute.rs b/apps/labrinth/src/routes/v2_reroute.rs index 0a99b0796c..5576c0f2fa 100644 --- a/apps/labrinth/src/routes/v2_reroute.rs +++ b/apps/labrinth/src/routes/v2_reroute.rs @@ -45,7 +45,7 @@ where // This should not be used on the fallback no-route-found handler pub fn flatten_404_error(res: ApiError) -> Result { match res { - ApiError::NotFound => Ok(HttpResponse::NotFound().body("")), + ApiError::NotFound(_) => Ok(HttpResponse::NotFound().body("")), _ => Err(res), } } diff --git a/apps/labrinth/src/routes/v3/analytics_get/facets/fixed.rs b/apps/labrinth/src/routes/v3/analytics_get/facets/fixed.rs index 14b1f68911..02a37254bb 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/facets/fixed.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/facets/fixed.rs @@ -3,6 +3,7 @@ use super::{ AnalyticsFacets, ProjectDownloadsFacets, ProjectPlaytimeFacets, ProjectViewsFacets, }; +use crate::util::error::ApiContext as _; use crate::{ database::PgPool, models::v3::analytics::DownloadReason, routes::ApiError, util::tags::valid_download_tags, @@ -13,7 +14,9 @@ pub async fn fetch( pool: &PgPool, redis: &RedisPool, ) -> Result { - let tags = valid_download_tags(pool, redis).await?; + let tags = valid_download_tags(pool, redis) + .await + .wrap_api_err("executing `valid_download_tags`")?; let mut loaders = tags.loaders.iter().cloned().collect::>(); loaders.sort(); let mut game_versions = diff --git a/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs b/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs index 02218bcec9..c61ea4f565 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs @@ -1,3 +1,5 @@ +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use xredis::RedisPool; mod fixed; @@ -78,9 +80,12 @@ pub async fn fetch_facets( &session_queue, Scopes::ANALYTICS, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; - let facets = fixed::fetch(&pool, &redis).await?; + let facets = fixed::fetch(&pool, &redis) + .await + .wrap_api_err("executing `fixed::fetch`")?; Ok(web::Json(FacetsResponse { facets })) } diff --git a/apps/labrinth/src/routes/v3/analytics_get/metrics/affiliate_code_conversions.rs b/apps/labrinth/src/routes/v3/analytics_get/metrics/affiliate_code_conversions.rs index c62f1e0e0b..29e611ccab 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/metrics/affiliate_code_conversions.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/metrics/affiliate_code_conversions.rs @@ -1,3 +1,4 @@ +use crate::util::error::ApiContext as _; use futures::StreamExt; use serde::{Deserialize, Serialize}; @@ -91,7 +92,12 @@ pub(crate) async fn fetch( &filter_affiliate_code_ids, ) .fetch(pool); - while let Some(row) = rows.next().await.transpose()? { + while let Some(row) = rows + .next() + .await + .transpose() + .wrap_internal_err("fetching affiliate code conversions")? + { let bucket = row .bucket .wrap_internal_err("bucket should be non-null - query bug!")?; @@ -116,7 +122,8 @@ pub(crate) async fn fetch( AffiliateCodeConversions { conversions }, ), }), - )?; + ) + .wrap_api_err("executing `AffiliateCodeMetrics::Conversions`")?; } Ok(()) diff --git a/apps/labrinth/src/routes/v3/analytics_get/metrics/affiliate_code_revenue.rs b/apps/labrinth/src/routes/v3/analytics_get/metrics/affiliate_code_revenue.rs index 9873bc15cd..791dd8a3dd 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/metrics/affiliate_code_revenue.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/metrics/affiliate_code_revenue.rs @@ -1,3 +1,4 @@ +use crate::util::error::ApiContext as _; use futures::StreamExt; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -86,7 +87,12 @@ pub(crate) async fn fetch( &filter_affiliate_code_ids, ) .fetch(pool); - while let Some(row) = rows.next().await.transpose()? { + while let Some(row) = rows + .next() + .await + .transpose() + .wrap_internal_err("fetching affiliate code revenue")? + { let bucket = row .bucket .wrap_internal_err("bucket should be non-null - query bug!")?; @@ -110,7 +116,8 @@ pub(crate) async fn fetch( revenue, }), }), - )?; + ) + .wrap_api_err("executing `AffiliateCodeMetrics::Revenue`")?; } Ok(()) diff --git a/apps/labrinth/src/routes/v3/analytics_get/metrics/project_downloads.rs b/apps/labrinth/src/routes/v3/analytics_get/metrics/project_downloads.rs index 36e8aabe16..dfa2ed5f94 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/metrics/project_downloads.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/metrics/project_downloads.rs @@ -1,3 +1,4 @@ +use crate::util::error::ApiContext as _; use std::{ collections::{HashMap, HashSet}, sync::{ @@ -330,7 +331,8 @@ async fn fetch_dependent_version_projects( dependent_on_version_ids.into_iter().collect::>(); let versions = DBVersion::get_many(&dependent_on_version_ids, cx.pool, cx.redis) - .await?; + .await + .wrap_internal_err("fetching versions from database")?; let dependent_project_ids = versions .iter() @@ -367,7 +369,9 @@ pub(crate) async fn fetch( use ProjectDownloadsField as F; let uses = |field| metrics.bucket_by.contains(&field); let dependent_on_version_filter = - fetch_dependent_on_version_filter(metrics, cx.pool).await?; + fetch_dependent_on_version_filter(metrics, cx.pool) + .await + .wrap_api_err("fetching dependent on version filter")?; if !metrics.filter_by.dependent_project_id.is_empty() && dependent_on_version_filter.is_empty() { @@ -442,15 +446,23 @@ pub(crate) async fn fetch( .iter() .any(|(column_name, used)| *column_name == name && *used) }; - let mut cursor = query.fetch::()?; + let mut cursor = query + .fetch::() + .wrap_internal_err("fetching project-download pagination cursor")?; let mut rows = Vec::new(); - while let Some(row) = cursor.next().await? { + while let Some(row) = cursor + .next() + .await + .wrap_internal_err("fetching project downloads")? + { rows.push(row); } let dependent_version_projects = - fetch_dependent_version_projects(&rows, cx).await?; + fetch_dependent_version_projects(&rows, cx) + .await + .wrap_api_err("fetching dependent version projects")?; let mut buckets = HashMap::::new(); for row in rows { @@ -576,7 +588,8 @@ pub(crate) async fn fetch( downloads, }), }), - )?; + ) + .wrap_api_err("executing `ProjectMetrics::Downloads`")?; } Ok(()) diff --git a/apps/labrinth/src/routes/v3/analytics_get/metrics/project_playtime.rs b/apps/labrinth/src/routes/v3/analytics_get/metrics/project_playtime.rs index 91ce1cfc10..f5ce8767b0 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/metrics/project_playtime.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/metrics/project_playtime.rs @@ -1,3 +1,5 @@ +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use std::collections::HashMap; use const_format::formatcp; @@ -227,10 +229,16 @@ pub(crate) async fn fetch( query = filter_param.bind(query); } - let mut cursor = query.fetch::()?; + let mut cursor = query + .fetch::() + .wrap_internal_err("fetching project-playtime pagination cursor")?; let mut buckets = HashMap::::new(); - while let Some(row) = cursor.next().await? { + while let Some(row) = cursor + .next() + .await + .wrap_internal_err("fetching project playtime")? + { let project_id = if uses_column("use_project_id") && row.project_id.0 == 0 { parent_version_projects @@ -308,7 +316,8 @@ pub(crate) async fn fetch( seconds, }), }), - )?; + ) + .wrap_api_err("executing `ProjectMetrics::Playtime`")?; } Ok(()) diff --git a/apps/labrinth/src/routes/v3/analytics_get/metrics/project_revenue.rs b/apps/labrinth/src/routes/v3/analytics_get/metrics/project_revenue.rs index 0b292ae0f3..c03de3cf8c 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/metrics/project_revenue.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/metrics/project_revenue.rs @@ -1,3 +1,4 @@ +use crate::util::error::ApiContext as _; use futures::StreamExt; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -95,7 +96,12 @@ pub(crate) async fn fetch( user_id_bucket_project_ids, ) .fetch(pool); - while let Some(row) = rows.next().await.transpose()? { + while let Some(row) = rows + .next() + .await + .transpose() + .wrap_internal_err("fetching project revenue")? + { let bucket = row .bucket .wrap_internal_err("bucket should be non-null - query bug!")?; @@ -123,7 +129,8 @@ pub(crate) async fn fetch( revenue, }), }), - )?; + ) + .wrap_api_err("adding project revenue to time slice")?; } } diff --git a/apps/labrinth/src/routes/v3/analytics_get/metrics/project_views.rs b/apps/labrinth/src/routes/v3/analytics_get/metrics/project_views.rs index cd612c3c38..a1b276e99c 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/metrics/project_views.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/metrics/project_views.rs @@ -1,3 +1,5 @@ +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use std::collections::HashMap; use const_format::formatcp; @@ -188,10 +190,16 @@ pub(crate) async fn fetch( query = filter_param.bind(query); } - let mut cursor = query.fetch::()?; + let mut cursor = query + .fetch::() + .wrap_internal_err("fetching project-view pagination cursor")?; let mut buckets = HashMap::::new(); - while let Some(row) = cursor.next().await? { + while let Some(row) = cursor + .next() + .await + .wrap_internal_err("fetching project views")? + { let key = ViewBucket { bucket: row.bucket, project_id: row.project_id, @@ -252,7 +260,8 @@ pub(crate) async fn fetch( views, }), }), - )?; + ) + .wrap_api_err("executing `ProjectMetrics::Views`")?; } Ok(()) diff --git a/apps/labrinth/src/routes/v3/analytics_get/mod.rs b/apps/labrinth/src/routes/v3/analytics_get/mod.rs index 72d1831dbc..93db02c048 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/mod.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/mod.rs @@ -7,6 +7,9 @@ //! requests, you have to zip together M arrays of N elements //! - this makes it inconvenient to have separate endpoints +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; + use xredis::RedisPool; pub mod facets; @@ -195,36 +198,30 @@ pub async fn fetch_analytics( &session_queue, Scopes::ANALYTICS, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let full_time_range = req.time_range.end - req.time_range.start; if full_time_range < TimeDelta::zero() { - return Err(ApiError::InvalidInput( - "End date must be after start date".into(), - )); + return Err(ApiError::Request(eyre::eyre!( + "End date must be after start date", + ))); } let (num_time_slices, resolution) = match req.time_range.resolution { TimeRangeResolution::Slices(slices) => { - let slices = i32::try_from(slices.get()).map_err(|_| { - ApiError::InvalidInput( - "Number of slices must fit into an `i32`".into(), - ) - })?; + let slices = i32::try_from(slices.get()) + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("number of slices must fit into an `i32`")?; let resolution = full_time_range / slices; (slices as usize, resolution) } TimeRangeResolution::Minutes(resolution_minutes) => { let resolution_minutes = i64::try_from(resolution_minutes.get()) - .map_err(|_| { - ApiError::InvalidInput( - "Resolution must fit into a `i64`".into(), - ) - })?; + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("resolution must fit into a `i64`")?; let resolution = TimeDelta::try_minutes(resolution_minutes) - .ok_or_else(|| { - ApiError::InvalidInput("Resolution overflow".into()) - })?; + .wrap_request_err_with(|| "resolution overflow")?; let num_slices = full_time_range.as_seconds_f64() / resolution.as_seconds_f64(); @@ -248,7 +245,9 @@ pub async fn fetch_analytics( let project_ids = { if req.project_ids.is_empty() { - DBUser::get_projects(user.id.into(), &**pool, &redis).await? + DBUser::get_projects(user.id.into(), &**pool, &redis) + .await + .wrap_internal_err("fetching users from database")? } else { req.project_ids .iter() @@ -258,7 +257,9 @@ pub async fn fetch_analytics( }; let project_ids = - filter_allowed_project_ids(&project_ids, &user, &pool, &redis).await?; + filter_allowed_project_ids(&project_ids, &user, &pool, &redis) + .await + .wrap_api_err("filtering allowed project ids")?; let project_id_values = project_ids.iter().map(|id| id.0).collect::>(); @@ -271,7 +272,8 @@ pub async fn fetch_analytics( &project_id_values, ) .fetch_all(&**pool) - .await?; + .await + .wrap_internal_err("fetching parent versions from database")?; let parent_version_ids = parent_versions .iter() .map(|version| DBVersionId(version.id)) @@ -281,7 +283,9 @@ pub async fn fetch_analytics( .map(|version| (DBVersionId(version.id), DBProjectId(version.mod_id))) .collect::>(); let parent_version_data = - DBVersion::get_many(&parent_version_ids, &**pool, &redis).await?; + DBVersion::get_many(&parent_version_ids, &**pool, &redis) + .await + .wrap_internal_err("fetching versions from database")?; let visible_version_ids = filter_visible_version_ids( parent_version_data .iter() @@ -291,7 +295,8 @@ pub async fn fetch_analytics( &pool, &redis, ) - .await?; + .await + .wrap_api_err("filtering visible version ids")?; let mut project_events = parent_version_data .iter() .filter(|version| { @@ -315,13 +320,15 @@ pub async fn fetch_analytics( &req.time_range, &pool, ) - .await?, + .await + .wrap_api_err("fetching project status change events")?, ); project_events.sort_by_key(|event| event.timestamp); let affiliate_code_ids = DBAffiliateCode::get_by_affiliate(user.id.into(), &**pool) - .await? + .await + .wrap_internal_err("fetching affiliate codes from database")? .into_iter() .map(|code| code.id) .collect::>(); @@ -340,12 +347,15 @@ pub async fn fetch_analytics( }; if let Some(metrics) = &req.return_metrics.project_views { - metrics::fetch_project_views(&mut query_clickhouse_cx, metrics).await?; + metrics::fetch_project_views(&mut query_clickhouse_cx, metrics) + .await + .wrap_api_err("fetching project views")?; } if let Some(metrics) = &req.return_metrics.project_downloads { metrics::fetch_project_downloads(&mut query_clickhouse_cx, metrics) - .await?; + .await + .wrap_api_err("fetching project downloads")?; } if let Some(metrics) = &req.return_metrics.project_playtime { @@ -354,17 +364,21 @@ pub async fn fetch_analytics( &parent_version_projects, metrics, ) - .await?; + .await + .wrap_api_err("fetching project playtime")?; } if let Some(metrics) = &req.return_metrics.affiliate_code_clicks { metrics::fetch_affiliate_code_clicks(&mut query_clickhouse_cx, metrics) - .await?; + .await + .wrap_api_err("fetching affiliate code clicks")?; } if let Some(metrics) = &req.return_metrics.project_revenue { if !scopes.contains(Scopes::PAYOUTS_READ) { - return Err(AuthenticationError::InvalidCredentials.into()); + return Err(ApiError::Auth(eyre::eyre!( + AuthenticationError::InvalidCredentials + ))); } let user_id_bucket_project_ids = sqlx::query!( @@ -381,7 +395,8 @@ pub async fn fetch_analytics( DBUserId::from(user.id).0, ) .fetch_all(&**pool) - .await? + .await + .wrap_internal_err("fetching user IDs from database")? .into_iter() .map(|row| row.id) .collect::>(); @@ -396,7 +411,8 @@ pub async fn fetch_analytics( user.role.is_mod(), metrics, ) - .await?; + .await + .wrap_api_err("checking mod")?; } if let Some(metrics) = &req.return_metrics.affiliate_code_conversions { @@ -408,12 +424,15 @@ pub async fn fetch_analytics( num_time_slices, metrics, ) - .await?; + .await + .wrap_api_err("fetching affiliate code conversions")?; } if let Some(metrics) = &req.return_metrics.affiliate_code_revenue { if !scopes.contains(Scopes::PAYOUTS_READ) { - return Err(AuthenticationError::InvalidCredentials.into()); + return Err(ApiError::Auth(eyre::eyre!( + AuthenticationError::InvalidCredentials + ))); } metrics::fetch_affiliate_code_revenue( @@ -424,12 +443,17 @@ pub async fn fetch_analytics( num_time_slices, metrics, ) - .await?; + .await + .wrap_api_err("fetching affiliate code revenue")?; } let projects = - fetch_response_projects(&mut time_slices, &user, &pool, &redis).await?; - let users = fetch_response_users(&time_slices, &pool, &redis).await?; + fetch_response_projects(&mut time_slices, &user, &pool, &redis) + .await + .wrap_api_err("fetching response projects")?; + let users = fetch_response_users(&time_slices, &pool, &redis) + .await + .wrap_api_err("fetching response users")?; Ok(web::Json(GetResponse { metrics: time_slices, @@ -531,14 +555,17 @@ async fn fetch_response_projects( } let project_ids = project_ids.into_iter().collect::>(); - let projects = DBProject::get_many_ids(&project_ids, pool, redis).await?; + let projects = DBProject::get_many_ids(&project_ids, pool, redis) + .await + .wrap_api_err("fetching analytics projects")?; let visible_project_ids = filter_visible_project_ids( projects.iter().map(|project| &project.inner).collect(), &Some(user.clone()), pool, false, ) - .await? + .await + .wrap_api_err("filtering visible project ids")? .into_iter() .collect::>(); @@ -580,7 +607,9 @@ async fn fetch_response_users( return Ok(HashMap::new()); } - let users = DBUser::get_many_ids(&user_ids, pool, redis).await?; + let users = DBUser::get_many_ids(&user_ids, pool, redis) + .await + .wrap_internal_err("fetching users from database")?; Ok(users .into_iter() @@ -649,7 +678,10 @@ async fn fetch_project_status_change_events( time_range.end, ) .fetch_all(&**pool) - .await?; + .await + .wrap_internal_err( + "querying database for `fetch_project_status_change_events`", + )?; Ok(rows .into_iter() @@ -798,14 +830,21 @@ where for filter_param in filter_params { query = filter_param.bind(query); } - let mut cursor = query.fetch::()?; - - while let Some(row) = cursor.next().await? { + let mut cursor = query + .fetch::() + .wrap_internal_err("fetching analytics pagination cursor")?; + + while let Some(row) = cursor + .next() + .await + .wrap_internal_err("querying database for `query_clickhouse`")? + { if !row_filter(&row) { continue; } let bucket = row_get_bucket(&row) as usize; - add_to_time_slice(cx.time_slices, bucket, row_to_analytics(row))?; + add_to_time_slice(cx.time_slices, bucket, row_to_analytics(row)) + .wrap_api_err("executing `row_to_analytics`")?; } Ok(()) @@ -827,11 +866,7 @@ pub(crate) fn add_to_time_slice( }; let num_time_slices = time_slices.len(); - let slice = time_slices.get_mut(bucket).ok_or_else(|| { - ApiError::InvalidInput( - format!("bucket {bucket} returned by query out of range for {num_time_slices} - query bug!") - ) - })?; + let slice = time_slices.get_mut(bucket).wrap_request_err_with(|| format!("bucket {bucket} returned by query out of range for {num_time_slices} - query bug!"))?; slice.0.push(data); Ok(()) @@ -843,7 +878,9 @@ async fn filter_allowed_project_ids( pool: &PgPool, redis: &RedisPool, ) -> Result, ApiError> { - let projects = DBProject::get_many_ids(project_ids, pool, redis).await?; + let projects = DBProject::get_many_ids(project_ids, pool, redis) + .await + .wrap_api_err("fetching projects for analytics authorization")?; let team_ids = projects .iter() @@ -852,7 +889,8 @@ async fn filter_allowed_project_ids( let team_members = database::models::DBTeamMember::get_from_team_full_many( &team_ids, pool, redis, ) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; let organization_ids = projects .iter() @@ -863,7 +901,8 @@ async fn filter_allowed_project_ids( pool, redis, ) - .await?; + .await + .wrap_internal_err("fetching organizations from database")?; let organization_team_ids = organizations .iter() @@ -875,7 +914,8 @@ async fn filter_allowed_project_ids( pool, redis, ) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; Ok(projects .into_iter() diff --git a/apps/labrinth/src/routes/v3/analytics_get/old.rs b/apps/labrinth/src/routes/v3/analytics_get/old.rs index 060a0299d7..a164921783 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/old.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/old.rs @@ -1,5 +1,8 @@ //! TODO: this module should be removed; it is superseded by `analytics_get` +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; + use super::ApiError; use crate::database; use crate::database::PgPool; @@ -13,7 +16,6 @@ use crate::{ use actix_web::{HttpRequest, HttpResponse, get, web}; use ariadne::ids::base62_impl::to_base62; use chrono::{DateTime, Duration, Utc}; -use eyre::eyre; use serde::{Deserialize, Serialize}; use sqlx::postgres::types::PgInterval; use std::collections::HashMap; @@ -77,13 +79,15 @@ pub async fn playtimes_get( Scopes::ANALYTICS, ) .await - .map(|x| x.1)?; + .map(|x| x.1) + .wrap_auth_err("authenticating API request")?; let project_ids = data .project_ids .as_ref() .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; + .transpose() + .wrap_request_err("deserializing JSON data")?; let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); let end_date = data.end_date.unwrap_or(Utc::now()); @@ -95,7 +99,9 @@ pub async fn playtimes_get( // - Filter out unauthorized projects/versions // - If no project_ids or version_ids are provided, we default to all projects the user has access to let project_ids = - filter_allowed_ids(project_ids, user, &pool, &redis, None).await?; + filter_allowed_ids(project_ids, user, &pool, &redis, None) + .await + .wrap_api_err("filtering authorized playtime project IDs")?; // Get the views let playtimes = crate::clickhouse::fetch_playtimes( @@ -105,7 +111,8 @@ pub async fn playtimes_get( resolution_minutes, clickhouse.into_inner(), ) - .await?; + .await + .wrap_api_err("fetching project playtime from ClickHouse")?; let mut hm = HashMap::new(); for playtime in playtimes { @@ -159,13 +166,15 @@ pub async fn views_get( Scopes::ANALYTICS, ) .await - .map(|x| x.1)?; + .map(|x| x.1) + .wrap_auth_err("authenticating API request")?; let project_ids = data .project_ids .as_ref() .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; + .transpose() + .wrap_request_err("deserializing JSON data")?; let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); let end_date = data.end_date.unwrap_or(Utc::now()); @@ -177,7 +186,9 @@ pub async fn views_get( // - Filter out unauthorized projects/versions // - If no project_ids or version_ids are provided, we default to all projects the user has access to let project_ids = - filter_allowed_ids(project_ids, user, &pool, &redis, None).await?; + filter_allowed_ids(project_ids, user, &pool, &redis, None) + .await + .wrap_api_err("filtering authorized view project IDs")?; // Get the views let views = crate::clickhouse::fetch_views( @@ -187,7 +198,8 @@ pub async fn views_get( resolution_minutes, clickhouse.into_inner(), ) - .await?; + .await + .wrap_api_err("fetching project views from ClickHouse")?; let mut hm = HashMap::new(); for views in views { @@ -241,13 +253,15 @@ pub async fn downloads_get( Scopes::ANALYTICS, ) .await - .map(|x| x.1)?; + .map(|x| x.1) + .wrap_auth_err("authenticating API request")?; let project_ids = data .project_ids .as_ref() .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; + .transpose() + .wrap_request_err("deserializing JSON data")?; let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); let end_date = data.end_date.unwrap_or(Utc::now()); @@ -260,7 +274,8 @@ pub async fn downloads_get( // - If no project_ids or version_ids are provided, we default to all projects the user has access to let project_ids = filter_allowed_ids(project_ids, user_option, &pool, &redis, None) - .await?; + .await + .wrap_api_err("filtering authorized download project IDs")?; // Get the downloads let downloads = crate::clickhouse::fetch_downloads( @@ -270,7 +285,8 @@ pub async fn downloads_get( resolution_minutes, clickhouse.into_inner(), ) - .await?; + .await + .wrap_api_err("fetching project downloads from ClickHouse")?; let mut hm = HashMap::new(); for downloads in downloads { @@ -323,13 +339,15 @@ pub async fn revenue_get( Scopes::PAYOUTS_READ, ) .await - .map(|x| x.1)?; + .map(|x| x.1) + .wrap_auth_err("authenticating API request")?; let project_ids = data .project_ids .as_ref() .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; + .transpose() + .wrap_request_err("deserializing JSON data")?; let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); let end_date = data.end_date.unwrap_or(Utc::now()); @@ -357,13 +375,15 @@ pub async fn revenue_get( &redis, Some(true), ) - .await?; + .await + .wrap_api_err("filtering authorized revenue project IDs")?; let duration: PgInterval = Duration::minutes(resolution_minutes as i64) .try_into() - .map_err(|_| { - ApiError::Request(eyre!("Invalid `resolution_minutes`")) - })?; + .map_err(|err: Box| { + eyre::eyre!("{err}") + }) + .wrap_request_err("invalid `resolution_minutes`")?; // Get the revenue data let project_ids = project_ids.unwrap_or_default(); @@ -387,7 +407,7 @@ pub async fn revenue_get( duration, ) .fetch_all(&**pool) - .await?.into_iter().map(|x| PayoutValue { + .await.wrap_internal_err("fetching payouts values from database")?.into_iter().map(|x| PayoutValue { mod_id: x.mod_id, amount_sum: x.amount_sum, interval_start: x.interval_start, @@ -406,7 +426,7 @@ pub async fn revenue_get( duration, ) .fetch_all(&**pool) - .await?.into_iter().map(|x| PayoutValue { + .await.wrap_internal_err("querying database for `revenue_get`")?.into_iter().map(|x| PayoutValue { mod_id: x.mod_id, amount_sum: x.amount_sum, interval_start: x.interval_start, @@ -476,13 +496,15 @@ pub async fn countries_downloads_get( Scopes::ANALYTICS, ) .await - .map(|x| x.1)?; + .map(|x| x.1) + .wrap_auth_err("authenticating API request")?; let project_ids = data .project_ids .as_ref() .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; + .transpose() + .wrap_request_err("deserializing JSON data")?; let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); let end_date = data.end_date.unwrap_or(Utc::now()); @@ -491,7 +513,11 @@ pub async fn countries_downloads_get( // - Filter out unauthorized projects/versions // - If no project_ids or version_ids are provided, we default to all projects the user has access to let project_ids = - filter_allowed_ids(project_ids, user, &pool, &redis, None).await?; + filter_allowed_ids(project_ids, user, &pool, &redis, None) + .await + .wrap_api_err( + "filtering authorized download-country project IDs", + )?; // Get the countries let countries = crate::clickhouse::fetch_countries_downloads( @@ -500,7 +526,8 @@ pub async fn countries_downloads_get( end_date, clickhouse.into_inner(), ) - .await?; + .await + .wrap_api_err("fetching download countries from ClickHouse")?; let mut hm = HashMap::new(); for views in countries { @@ -562,13 +589,15 @@ pub async fn countries_views_get( Scopes::ANALYTICS, ) .await - .map(|x| x.1)?; + .map(|x| x.1) + .wrap_auth_err("authenticating API request")?; let project_ids = data .project_ids .as_ref() .map(|ids| serde_json::from_str::>(ids)) - .transpose()?; + .transpose() + .wrap_request_err("deserializing JSON data")?; let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); let end_date = data.end_date.unwrap_or(Utc::now()); @@ -577,7 +606,9 @@ pub async fn countries_views_get( // - Filter out unauthorized projects/versions // - If no project_ids or version_ids are provided, we default to all projects the user has access to let project_ids = - filter_allowed_ids(project_ids, user, &pool, &redis, None).await?; + filter_allowed_ids(project_ids, user, &pool, &redis, None) + .await + .wrap_api_err("filtering authorized view-country project IDs")?; // Get the countries let countries = crate::clickhouse::fetch_countries_views( @@ -586,7 +617,8 @@ pub async fn countries_views_get( end_date, clickhouse.into_inner(), ) - .await?; + .await + .wrap_api_err("fetching view countries from ClickHouse")?; let mut hm = HashMap::new(); for views in countries { @@ -635,7 +667,8 @@ async fn filter_allowed_ids( if project_ids.is_none() && !remove_defaults.unwrap_or(false) { project_ids = Some( user_item::DBUser::get_projects(user.id.into(), &***pool, redis) - .await? + .await + .wrap_internal_err("deleting user from database")? .into_iter() .map(|x| ProjectId::from(x).to_string()) .collect(), @@ -650,7 +683,8 @@ async fn filter_allowed_ids( &***pool, redis, ) - .await?; + .await + .wrap_api_err("fetching analytics projects")?; let team_ids = projects_data .iter() @@ -660,7 +694,8 @@ async fn filter_allowed_ids( database::models::DBTeamMember::get_from_team_full_many( &team_ids, &***pool, redis, ) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; let organization_ids = projects_data .iter() @@ -671,7 +706,8 @@ async fn filter_allowed_ids( &***pool, redis, ) - .await?; + .await + .wrap_internal_err("fetching organizations from database")?; let organization_team_ids = organizations .iter() @@ -683,7 +719,8 @@ async fn filter_allowed_ids( &***pool, redis, ) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; let ids = projects_data .into_iter() diff --git a/apps/labrinth/src/routes/v3/blocked_users.rs b/apps/labrinth/src/routes/v3/blocked_users.rs index 4090e3ac1c..2cdd00bbc9 100644 --- a/apps/labrinth/src/routes/v3/blocked_users.rs +++ b/apps/labrinth/src/routes/v3/blocked_users.rs @@ -6,6 +6,7 @@ use crate::database::models::friend_item::DBFriend; use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::util::error::Context as _; use actix_web::{HttpRequest, delete, get, post, web}; use ariadne::ids::UserId; use eyre::eyre; @@ -34,30 +35,43 @@ pub async fn block_user( &session_queue, Scopes::USER_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let user_id = info.into_inner().0; - let Some(blocked) = DBUser::get(&user_id, &**pool, &redis).await? else { - return Err(ApiError::NotFound); + let Some(blocked) = DBUser::get(&user_id, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")? + else { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; if blocked.id == user.id.into() { return Err(ApiError::Request(eyre!("you cannot block yourself"))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; - DBFriend::remove(user.id.into(), blocked.id, &mut transaction).await?; + DBFriend::remove(user.id.into(), blocked.id, &mut transaction) + .await + .wrap_internal_err("deleting friend from database")?; DBBlockedUser { user_id: user.id.into(), blocked_id: blocked.id, } .insert(&mut transaction) - .await?; + .await + .wrap_internal_err("inserting database records for `block_user`")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(()) } @@ -79,15 +93,21 @@ pub async fn unblock_user( &session_queue, Scopes::USER_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let user_id = info.into_inner().0; - let Some(blocked) = DBUser::get(&user_id, &**pool, &redis).await? else { - return Err(ApiError::NotFound); + let Some(blocked) = DBUser::get(&user_id, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")? + else { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; - DBBlockedUser::remove(user.id.into(), blocked.id, &**pool).await?; + DBBlockedUser::remove(user.id.into(), blocked.id, &**pool) + .await + .wrap_internal_err("deleting blocked user from database")?; Ok(()) } @@ -108,11 +128,13 @@ pub async fn get_blocked_users( &session_queue, Scopes::USER_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let blocked = DBBlockedUser::get_blocked_for_user(user.id.into(), &**pool) - .await? + .await + .wrap_internal_err("fetching blocked user from database")? .into_iter() .map(UserId::from) .collect(); diff --git a/apps/labrinth/src/routes/v3/collections.rs b/apps/labrinth/src/routes/v3/collections.rs index fe549298cf..b5e5d266a8 100644 --- a/apps/labrinth/src/routes/v3/collections.rs +++ b/apps/labrinth/src/routes/v3/collections.rs @@ -14,6 +14,7 @@ use crate::models::v3::user_limits::UserLimits; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::routes::v3::project_creation::CreateError; +use crate::util::error::ApiContext as _; use crate::util::error::Context; use crate::util::img::delete_old_images; use crate::util::routes::read_limited_from_payload; @@ -157,16 +158,20 @@ pub async fn collections_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let ids = serde_json::from_str::>(&ids.ids)?; + let ids = serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing JSON data")?; let ids = ids .into_iter() .map(|x| { parse_base62(x).map(|x| database::models::DBCollectionId(x as i64)) }) - .collect::, _>>()?; + .collect::, _>>() + .wrap_request_err("parsing collection IDs")?; let collections_data = - database::models::DBCollection::get_many(&ids, &**pool, &redis).await?; + database::models::DBCollection::get_many(&ids, &**pool, &redis) + .await + .wrap_internal_err("fetching collections from database")?; let user_option = get_user_from_headers( &req, @@ -181,7 +186,8 @@ pub async fn collections_get( let collections = filter_visible_collections(collections_data, &user_option, false) - .await?; + .await + .wrap_api_err("filtering visible collections")?; Ok(HttpResponse::Ok().json(collections)) } @@ -197,9 +203,13 @@ pub async fn collection_get( ) -> Result { let string = info.into_inner().0; - let id = database::models::DBCollectionId(parse_base62(&string)? as i64); + let id = database::models::DBCollectionId( + parse_base62(&string).wrap_request_err("parsing collection ID")? as i64, + ); let collection_data = - database::models::DBCollection::get(id, &**pool, &redis).await?; + database::models::DBCollection::get(id, &**pool, &redis) + .await + .wrap_internal_err("fetching collection from database")?; let user_option = get_user_from_headers( &req, &**pool, @@ -212,11 +222,13 @@ pub async fn collection_get( .ok(); if let Some(data) = collection_data - && is_visible_collection(&data, &user_option, false).await? + && is_visible_collection(&data, &user_option, false) + .await + .wrap_api_err("checking collection visibility")? { return Ok(HttpResponse::Ok().json(Collection::from(data))); } - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } #[derive(Deserialize, Validate, utoipa::ToSchema)] @@ -256,17 +268,22 @@ pub async fn collection_edit( &session_queue, Scopes::COLLECTION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - new_collection.validate().map_err(|err| { - ApiError::Validation(validation_errors_to_string(err, None)) - })?; + new_collection + .validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; let string = info.into_inner().0; - let id = database::models::DBCollectionId(parse_base62(&string)? as i64); - let result = - database::models::DBCollection::get(id, &**pool, &redis).await?; + let id = database::models::DBCollectionId( + parse_base62(&string).wrap_request_err("parsing collection ID")? as i64, + ); + let result = database::models::DBCollection::get(id, &**pool, &redis) + .await + .wrap_internal_err("fetching collection from database")?; if let Some(collection_item) = result { if !can_modify_collection(&collection_item, &user) { @@ -275,7 +292,10 @@ pub async fn collection_edit( let id = collection_item.id; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; if let Some(name) = &new_collection.name { sqlx::query!( @@ -288,7 +308,8 @@ pub async fn collection_edit( id as database::models::ids::DBCollectionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `collection_edit`")?; } if let Some(description) = &new_collection.description { @@ -302,7 +323,8 @@ pub async fn collection_edit( id as database::models::ids::DBCollectionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `collection_edit`")?; } if let Some(status) = &new_collection.status { @@ -310,9 +332,9 @@ 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::Auth(eyre::eyre!( + "You don't have permission to set this status!", + ))); } sqlx::query!( @@ -325,7 +347,8 @@ pub async fn collection_edit( id as database::models::ids::DBCollectionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `collection_edit`")?; } if let Some(new_project_ids) = &new_collection.new_projects { @@ -338,7 +361,8 @@ pub async fn collection_edit( collection_item.id as database::models::ids::DBCollectionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("fetching new project IDs from database")?; let collection_item_ids = new_project_ids .iter() @@ -349,9 +373,10 @@ pub async fn collection_edit( let project = database::models::DBProject::get( project_id, &**pool, &redis, ) - .await? + .await + .wrap_api_err("fetching project from database")? .wrap_request_err_with(|| { - eyre!("The specified project {project_id} does not exist!") + eyre!("the specified project `{project_id}` does not exist") })?; validated_project_ids.push(project.inner.id.0); } @@ -366,7 +391,8 @@ pub async fn collection_edit( &validated_project_ids[..], ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `collection_edit`")?; sqlx::query!( " @@ -377,16 +403,21 @@ pub async fn collection_edit( collection_item.id as database::models::ids::DBCollectionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `collection_edit`")?; } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; database::models::DBCollection::clear_cache(collection_item.id, &redis) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -420,18 +451,20 @@ pub async fn collection_icon_edit( &session_queue, Scopes::COLLECTION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let string = info.into_inner().0; - let id = database::models::DBCollectionId(parse_base62(&string)? as i64); + let id = database::models::DBCollectionId( + parse_base62(&string).wrap_request_err("parsing collection ID")? as i64, + ); let collection_item = database::models::DBCollection::get(id, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified collection does not exist!".to_string(), - ) + .await + .wrap_internal_err("fetching collection from database")? + .wrap_request_err_with(|| { + "the specified collection does not exist!".to_string() })?; if !can_modify_collection(&collection_item, &user) { @@ -444,14 +477,16 @@ pub async fn collection_icon_edit( FileHostPublicity::Public, &**file_host, ) - .await?; + .await + .wrap_api_err("deleting old images")?; let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", ) - .await?; + .await + .wrap_api_err("executing `read_limited_from_payload`")?; let collection_id: CollectionId = collection_item.id.into(); let upload_result = crate::util::img::upload_image_optimized( @@ -463,9 +498,13 @@ pub async fn collection_icon_edit( Some(1.0), &**file_host, ) - .await?; + .await + .wrap_api_err("uploading image")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " @@ -479,11 +518,16 @@ pub async fn collection_icon_edit( collection_item.id as database::models::ids::DBCollectionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `collection_icon_edit`")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; database::models::DBCollection::clear_cache(collection_item.id, &redis) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::NoContent().body("")) } @@ -505,18 +549,20 @@ pub async fn delete_collection_icon( &session_queue, Scopes::COLLECTION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let string = info.into_inner().0; - let id = database::models::DBCollectionId(parse_base62(&string)? as i64); + let id = database::models::DBCollectionId( + parse_base62(&string).wrap_request_err("parsing collection ID")? as i64, + ); let collection_item = database::models::DBCollection::get(id, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified collection does not exist!".to_string(), - ) + .await + .wrap_internal_err("fetching collection from database")? + .wrap_request_err_with(|| { + "the specified collection does not exist!".to_string() })?; if !can_modify_collection(&collection_item, &user) { return Ok(HttpResponse::Unauthorized().body("")); @@ -528,8 +574,12 @@ pub async fn delete_collection_icon( FileHostPublicity::Public, &**file_host, ) - .await?; - let mut transaction = pool.begin().await?; + .await + .wrap_api_err("deleting old images")?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " @@ -540,11 +590,16 @@ pub async fn delete_collection_icon( collection_item.id as database::models::ids::DBCollectionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `delete_collection_icon`")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; database::models::DBCollection::clear_cache(collection_item.id, &redis) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::NoContent().body("")) } @@ -565,37 +620,48 @@ pub async fn collection_delete( &session_queue, Scopes::COLLECTION_DELETE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let string = info.into_inner().0; - let id = database::models::DBCollectionId(parse_base62(&string)? as i64); + let id = database::models::DBCollectionId( + parse_base62(&string).wrap_request_err("parsing collection ID")? as i64, + ); let collection = database::models::DBCollection::get(id, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified collection does not exist!".to_string(), - ) + .await + .wrap_internal_err("fetching collection from database")? + .wrap_request_err_with(|| { + "the specified collection does not exist!".to_string() })?; if !can_modify_collection(&collection, &user) { return Ok(HttpResponse::Unauthorized().body("")); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let result = database::models::DBCollection::remove( collection.id, &mut transaction, &redis, ) - .await?; + .await + .wrap_internal_err("deleting collection from database")?; - transaction.commit().await?; - database::models::DBCollection::clear_cache(collection.id, &redis).await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; + database::models::DBCollection::clear_cache(collection.id, &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; if result.is_some() { Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } diff --git a/apps/labrinth/src/routes/v3/content/mod.rs b/apps/labrinth/src/routes/v3/content/mod.rs index 41807babd7..dc857b4d45 100644 --- a/apps/labrinth/src/routes/v3/content/mod.rs +++ b/apps/labrinth/src/routes/v3/content/mod.rs @@ -11,6 +11,7 @@ use crate::models::pats::Scopes; use crate::models::projects::{DependencyType, Version}; use crate::models::users::User; use crate::queue::session::AuthQueue; +use crate::util::error::ApiContext as _; use actix_web::{HttpRequest, post, web}; use ariadne::ids::base62_impl::parse_base62; use async_trait::async_trait; @@ -72,7 +73,8 @@ pub async fn resolve_content( modrinth_content_management::resolve_content(&mut provider, request) .await } - .map_err(resolve_error_to_api)?; + .map_err(resolve_error_to_api) + .wrap_api_err("executing `modrinth_content_management::resolve_content`")?; Ok(web::Json(plan)) } diff --git a/apps/labrinth/src/routes/v3/friends.rs b/apps/labrinth/src/routes/v3/friends.rs index 0f02c081da..5be589a6ac 100644 --- a/apps/labrinth/src/routes/v3/friends.rs +++ b/apps/labrinth/src/routes/v3/friends.rs @@ -13,6 +13,8 @@ use crate::routes::internal::statuses::{ }; use crate::sync::friends::RedisFriendsMessage; use crate::sync::status::get_user_status; +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use ariadne::networking::message::ServerToClientMessage; use chrono::Utc; @@ -42,41 +44,54 @@ pub async fn add_friend( &session_queue, Scopes::USER_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let string = info.into_inner().0; - let Some(friend) = DBUser::get(&string, &**pool, &redis).await? else { - return Err(ApiError::NotFound); + let Some(friend) = DBUser::get(&string, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")? + else { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; - if DBBlockedUser::is_blocked(friend.id, user.id.into(), &**pool).await? { - return Err(ApiError::InvalidInput( - "You've been blocked the other user!".to_string(), - )); + if DBBlockedUser::is_blocked(friend.id, user.id.into(), &**pool) + .await + .wrap_internal_err("checking whether target user blocked requester")? + { + return Err(ApiError::Request(eyre::eyre!( + "You've been blocked the other user!", + ))); } else if DBBlockedUser::is_blocked(user.id.into(), friend.id, &**pool) - .await? + .await + .wrap_internal_err("checking whether requester blocked target user")? { - return Err(ApiError::InvalidInput( - "You've blocked the other user!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You've blocked the other user!", + ))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; if let Some(friend) = - DBFriend::get_friend(user.id.into(), friend.id, &**pool).await? + DBFriend::get_friend(user.id.into(), friend.id, &**pool) + .await + .wrap_internal_err("fetching friend from database")? { if friend.accepted { - return Err(ApiError::InvalidInput( - "You are already friends with this user!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You are already friends with this user!", + ))); } if !friend.accepted && user.id != friend.friend_id.into() { - return Err(ApiError::InvalidInput( - "You cannot accept your own friend request!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You cannot accept your own friend request!", + ))); } DBFriend::update_friend( @@ -85,7 +100,8 @@ pub async fn add_friend( true, &mut transaction, ) - .await?; + .await + .wrap_internal_err("updating friend in database")?; async fn send_friend_status( user_id: DBUserId, @@ -103,27 +119,30 @@ pub async fn add_friend( status: friend_status, }, ) - .await?; + .await + .wrap_internal_err("updating friend in database")?; } Ok(()) } send_friend_status(friend.user_id, friend.friend_id, &db, &redis) - .await?; + .await + .wrap_api_err("executing `send_friend_status`")?; send_friend_status(friend.friend_id, friend.user_id, &db, &redis) - .await?; + .await + .wrap_api_err("executing `send_friend_status`")?; } else { if friend.id == user.id.into() { - return Err(ApiError::InvalidInput( - "You cannot add yourself as a friend!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You cannot add yourself as a friend!", + ))); } if !friend.allow_friend_requests { - return Err(ApiError::InvalidInput( - "Friend requests are disabled for this user!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Friend requests are disabled for this user!", + ))); } DBFriend { @@ -133,17 +152,26 @@ pub async fn add_friend( accepted: false, } .insert(&mut transaction) - .await?; + .await + .wrap_internal_err( + "inserting database records for `send_friend_status`", + )?; send_message_to_user( &db, friend.id.into(), &ServerToClientMessage::FriendRequest { from: user.id }, ) - .await?; + .await + .wrap_internal_err( + "inserting database records for `send_friend_status`", + )?; } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().body("")) } @@ -166,29 +194,41 @@ pub async fn remove_friend( &session_queue, Scopes::USER_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let string = info.into_inner().0; - let friend = DBUser::get(&string, &**pool, &redis).await?; + let friend = DBUser::get(&string, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")?; if let Some(friend) = friend { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; - DBFriend::remove(user.id.into(), friend.id, &mut transaction).await?; + DBFriend::remove(user.id.into(), friend.id, &mut transaction) + .await + .wrap_internal_err("deleting friend from database")?; send_message_to_user( &db, friend.id.into(), &ServerToClientMessage::FriendRequestRejected { from: user.id }, ) - .await?; + .await + .wrap_internal_err("deleting friend from database")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -208,11 +248,13 @@ pub async fn friends( &session_queue, Scopes::USER_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let friends = DBFriend::get_user_friends(user.id.into(), None, &**pool) - .await? + .await + .wrap_internal_err("fetching friends from database")? .into_iter() .map(UserFriend::from) .collect::>(); diff --git a/apps/labrinth/src/routes/v3/images.rs b/apps/labrinth/src/routes/v3/images.rs index 6b53cf0fab..7e45da50c1 100644 --- a/apps/labrinth/src/routes/v3/images.rs +++ b/apps/labrinth/src/routes/v3/images.rs @@ -11,6 +11,8 @@ use crate::models::ids::{ReportId, ThreadMessageId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use crate::util::img::upload_image_optimized; use crate::util::routes::read_limited_from_payload; use actix_web::{HttpRequest, HttpResponse, post, web}; @@ -68,7 +70,8 @@ pub async fn images_add( &session_queue, context.relevant_scope(), ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; // Attempt to associated a supplied id with the context @@ -77,25 +80,28 @@ pub async fn images_add( ImageContext::Project { project_id } => { if let Some(id) = data.project_id { let project = - project_item::DBProject::get(&id, &**pool, &redis).await?; + project_item::DBProject::get(&id, &**pool, &redis) + .await + .wrap_api_err("fetching project from database")?; if let Some(project) = project { if is_team_member_project( &project.inner, &Some(user.clone()), &pool, ) - .await? + .await + .wrap_api_err("checking team member project")? { *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::Auth(eyre::eyre!( + "You are not authorized to upload images for this project", + ))); } } else { - return Err(ApiError::InvalidInput( - "The project could not be found.".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "The project could not be found.", + ))); } } } @@ -103,7 +109,8 @@ pub async fn images_add( if let Some(id) = data.version_id { let version = version_item::DBVersion::get(id.into(), &**pool, &redis) - .await?; + .await + .wrap_internal_err("fetching version from database")?; if let Some(version) = version { if is_team_member_version( &version.inner, @@ -111,18 +118,19 @@ pub async fn images_add( &pool, &redis, ) - .await? + .await + .wrap_api_err("checking team member version")? { *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::Auth(eyre::eyre!( + "You are not authorized to upload images for this version", + ))); } } else { - return Err(ApiError::InvalidInput( - "The version could not be found.".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "The version could not be found.", + ))); } } } @@ -130,60 +138,63 @@ pub async fn images_add( if let Some(id) = data.thread_message_id { let thread_message = thread_item::DBThreadMessage::get(id.into(), &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The thread message could not found." - .to_string(), - ) + .await + .wrap_internal_err( + "fetching thread message from database", + )? + .wrap_request_err_with(|| { + "the thread message could not found.".to_string() })?; let thread = thread_item::DBThread::get(thread_message.thread_id, &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The thread associated with the thread message could not be found" - .to_string(), - ) - })?; - if is_authorized_thread(&thread, &user, &pool).await? { + .await.wrap_internal_err("fetching thread from database")? + .wrap_request_err_with(|| "the thread associated with the thread message could not be found" + .to_string())?; + if is_authorized_thread(&thread, &user, &pool) + .await + .wrap_api_err("checking thread authorization")? + { *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::Auth(eyre::eyre!( + "You are not authorized to upload images for this thread message", + ))); } } } ImageContext::Report { report_id } => { if let Some(id) = data.report_id { let report = report_item::DBReport::get(id.into(), &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The report could not be found.".to_string(), - ) - })?; - let thread = thread_item::DBThread::get(report.thread_id, &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The thread associated with the report could not be found.".to_string(), - ) + .await + .wrap_internal_err("fetching report from database")? + .wrap_request_err_with(|| { + "the report could not be found.".to_string() })?; - if is_authorized_thread(&thread, &user, &pool).await? { + let thread = thread_item::DBThread::get( + report.thread_id, + &**pool, + ) + .await + .wrap_internal_err("fetching thread from database")? + .wrap_request_err_with(|| { + "the thread associated with the report could not be found." + .to_string() + })?; + if is_authorized_thread(&thread, &user, &pool) + .await + .wrap_api_err("checking thread authorization")? + { *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::Auth(eyre::eyre!( + "You are not authorized to upload images for this report", + ))); } } } ImageContext::Unknown => { - return Err(ApiError::InvalidInput( - "Context must be one of: project, version, thread_message, report".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Context must be one of: project, version, thread_message, report", + ))); } } @@ -193,7 +204,8 @@ pub async fn images_add( 1_048_576, "Icons must be smaller than 1MiB", ) - .await?; + .await + .wrap_api_err("executing `read_limited_from_payload`")?; let content_length = bytes.len(); let upload_result = upload_image_optimized( @@ -205,12 +217,18 @@ pub async fn images_add( None, &**file_host, ) - .await?; + .await + .wrap_api_err("uploading image")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let db_image: database::models::DBImage = database::models::DBImage { - id: database::models::generate_image_id(&mut transaction).await?, + id: database::models::generate_image_id(&mut transaction) + .await + .wrap_internal_err("generating image ID")?, url: upload_result.url, raw_url: upload_result.raw_url, size: content_length as u64, @@ -252,7 +270,10 @@ pub async fn images_add( }; // Insert - db_image.insert(&mut transaction).await?; + db_image + .insert(&mut transaction) + .await + .wrap_internal_err("inserting database records for `images_add`")?; let image = Image { id: db_image.id.into(), @@ -263,7 +284,10 @@ pub async fn images_add( context, }; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::Ok().json(image)) } diff --git a/apps/labrinth/src/routes/v3/limits.rs b/apps/labrinth/src/routes/v3/limits.rs index f7ef89a48f..ab11d37a8e 100644 --- a/apps/labrinth/src/routes/v3/limits.rs +++ b/apps/labrinth/src/routes/v3/limits.rs @@ -1,4 +1,5 @@ use crate::database::PgPool; +use crate::util::error::Context as _; use crate::{ auth::get_user_from_headers, models::{pats::Scopes, v3::user_limits::UserLimits}, @@ -29,9 +30,12 @@ pub async fn get_project_limits( &session_queue, Scopes::empty(), ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; - let limits = UserLimits::get_for_projects(&user, &pool).await?; + let limits = UserLimits::get_for_projects(&user, &pool) + .await + .wrap_internal_err("fetching user limits from Redis")?; Ok(web::Json(limits)) } @@ -50,9 +54,12 @@ pub async fn get_organization_limits( &session_queue, Scopes::empty(), ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; - let limits = UserLimits::get_for_organizations(&user, &pool).await?; + let limits = UserLimits::get_for_organizations(&user, &pool) + .await + .wrap_internal_err("fetching user limits from Redis")?; Ok(web::Json(limits)) } @@ -71,8 +78,11 @@ pub async fn get_collection_limits( &session_queue, Scopes::empty(), ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; - let limits = UserLimits::get_for_collections(&user, &pool).await?; + let limits = UserLimits::get_for_collections(&user, &pool) + .await + .wrap_internal_err("fetching user limits from Redis")?; Ok(web::Json(limits)) } diff --git a/apps/labrinth/src/routes/v3/notifications.rs b/apps/labrinth/src/routes/v3/notifications.rs index bab2519802..1daa7acf3e 100644 --- a/apps/labrinth/src/routes/v3/notifications.rs +++ b/apps/labrinth/src/routes/v3/notifications.rs @@ -6,6 +6,7 @@ use crate::models::notifications::Notification; use crate::models::pats::Scopes; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::util::error::Context as _; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use serde::{Deserialize, Serialize}; use xredis::RedisPool; @@ -54,14 +55,16 @@ pub async fn notifications_get( &session_queue, Scopes::NOTIFICATION_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; use database::models::DBNotificationId; use database::models::notification_item::DBNotification; let notification_ids: Vec = - serde_json::from_str::>(ids.ids.as_str())? + serde_json::from_str::>(ids.ids.as_str()) + .wrap_request_err("deserializing JSON data")? .into_iter() .map(DBNotificationId::from) .collect(); @@ -71,7 +74,8 @@ pub async fn notifications_get( ¬ification_ids, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching notifications from database")?; let notifications: Vec = notifications_data .into_iter() @@ -108,7 +112,8 @@ pub async fn notification_get( &session_queue, Scopes::NOTIFICATION_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let id = info.into_inner().0; @@ -118,16 +123,17 @@ pub async fn notification_get( id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching notification from database")?; if let Some(data) = notification_data { if user.id == data.user_id.into() || user.role.is_admin() { Ok(HttpResponse::Ok().json(Notification::from(data))) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -157,7 +163,8 @@ pub async fn notification_read( &session_queue, Scopes::NOTIFICATION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let id = info.into_inner().0; @@ -167,29 +174,37 @@ pub async fn notification_read( id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching notification from database")?; if let Some(data) = notification_data { if data.user_id == user.id.into() || user.role.is_admin() { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; database::models::notification_item::DBNotification::read( id.into(), &mut transaction, &redis, ) - .await?; + .await + .wrap_internal_err("executing `DBNotification::read`")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::CustomAuthentication( - "You are not authorized to read this notification!".to_string(), - )) + Err(ApiError::Auth(eyre::eyre!( + "You are not authorized to read this notification!", + ))) } } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -219,7 +234,8 @@ pub async fn notification_delete( &session_queue, Scopes::NOTIFICATION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let id = info.into_inner().0; @@ -229,30 +245,37 @@ pub async fn notification_delete( id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching notification from database")?; if let Some(data) = notification_data { if data.user_id == user.id.into() || user.role.is_admin() { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; database::models::notification_item::DBNotification::remove( id.into(), &mut transaction, &redis, ) - .await?; + .await + .wrap_internal_err("deleting notification from database")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::CustomAuthentication( - "You are not authorized to delete this notification!" - .to_string(), - )) + Err(ApiError::Auth(eyre::eyre!( + "You are not authorized to delete this notification!", + ))) } } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -286,23 +309,29 @@ pub async fn notifications_read( &session_queue, Scopes::NOTIFICATION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let notification_ids = - serde_json::from_str::>(&ids.ids)? + serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing JSON data")? .into_iter() .map(|x| x.into()) .collect::>(); - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let notifications_data = database::models::notification_item::DBNotification::get_many( ¬ification_ids, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching notifications from database")?; let mut notifications: Vec = Vec::new(); @@ -318,9 +347,13 @@ pub async fn notifications_read( &mut transaction, &redis, ) - .await?; + .await + .wrap_internal_err("marking notifications as read")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().body("")) } @@ -355,23 +388,29 @@ pub async fn notifications_delete( &session_queue, Scopes::NOTIFICATION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let notification_ids = - serde_json::from_str::>(&ids.ids)? + serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing JSON data")? .into_iter() .map(|x| x.into()) .collect::>(); - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let notifications_data = database::models::notification_item::DBNotification::get_many( ¬ification_ids, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching notifications from database")?; let mut notifications: Vec = Vec::new(); @@ -387,9 +426,13 @@ pub async fn notifications_delete( &mut transaction, &redis, ) - .await?; + .await + .wrap_internal_err("deleting notifications from database")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().body("")) } diff --git a/apps/labrinth/src/routes/v3/oauth_clients.rs b/apps/labrinth/src/routes/v3/oauth_clients.rs index a2b3a5c52b..039045f28d 100644 --- a/apps/labrinth/src/routes/v3/oauth_clients.rs +++ b/apps/labrinth/src/routes/v3/oauth_clients.rs @@ -1,3 +1,5 @@ +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use std::{collections::HashSet, fmt::Display}; use xredis::RedisPool; @@ -71,23 +73,27 @@ pub async fn get_user_clients( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - let target_user = DBUser::get(&info.into_inner(), &**pool, &redis).await?; + let target_user = DBUser::get(&info.into_inner(), &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")?; if let Some(target_user) = target_user { 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::Auth(eyre::eyre!( + "You do not have permission to see the OAuth clients of this user!", + ))); } let clients = DBOAuthClient::get_all_user_clients(target_user.id, &**pool) - .await?; + .await + .wrap_internal_err("fetching OAuth clients from database")?; let response = clients .into_iter() @@ -96,7 +102,7 @@ pub async fn get_user_clients( Ok(HttpResponse::Ok().json(response)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -112,11 +118,13 @@ pub async fn get_client( id: web::Path, pool: web::Data, ) -> Result { - let clients = get_clients_inner(&[id.into_inner()], pool).await?; + let clients = get_clients_inner(&[id.into_inner()], pool) + .await + .wrap_api_err("fetching OAuth client")?; if let Some(client) = clients.into_iter().next() { Ok(HttpResponse::Ok().json(client)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -136,9 +144,12 @@ pub async fn get_clients( .ids .iter() .map(|id| parse_base62(id).map(OAuthClientId)) - .collect::>()?; + .collect::>() + .wrap_request_err("parsing OAuth client IDs")?; - let clients = get_clients_inner(&ids, pool).await?; + let clients = get_clients_inner(&ids, pool) + .await + .wrap_api_err("fetching clients inner")?; Ok(HttpResponse::Ok().json(clients)) } @@ -257,18 +268,24 @@ pub async fn oauth_client_delete( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - let client = - DBOAuthClient::get(client_id.into_inner().into(), &**pool).await?; + let client = DBOAuthClient::get(client_id.into_inner().into(), &**pool) + .await + .wrap_internal_err("fetching OAuth client from database")?; if let Some(client) = client { - client.validate_authorized(Some(¤t_user))?; - DBOAuthClient::remove(client.id, &**pool).await?; + client + .validate_authorized(Some(¤t_user)) + .wrap_api_err("validating authorized")?; + DBOAuthClient::remove(client.id, &**pool) + .await + .wrap_internal_err("validating oauth client")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -321,17 +338,23 @@ pub async fn oauth_client_edit( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - client_updates.validate().map_err(|e| { - ApiError::Validation(validation_errors_to_string(e, None)) - })?; + client_updates + .validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; if let Some(existing_client) = - DBOAuthClient::get(client_id.into_inner().into(), &**pool).await? + DBOAuthClient::get(client_id.into_inner().into(), &**pool) + .await + .wrap_internal_err("fetching OAuth client from database")? { - existing_client.validate_authorized(Some(¤t_user))?; + existing_client + .validate_authorized(Some(¤t_user)) + .wrap_api_err("authorizing OAuth client update")?; let mut updated_client = existing_client.clone(); let OAuthClientEdit { @@ -357,21 +380,31 @@ pub async fn oauth_client_edit( updated_client.description = description; } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; updated_client .update_editable_fields(&mut transaction) - .await?; + .await + .wrap_internal_err( + "updating database records for `oauth_client_edit`", + )?; if let Some(redirects) = redirect_uris { edit_redirects(redirects, &existing_client, &mut transaction) - .await?; + .await + .wrap_internal_err("updating redirects in database")?; } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::Ok().body("")) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -410,18 +443,20 @@ pub async fn oauth_client_icon_edit( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let client = DBOAuthClient::get((*client_id).into(), &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified client does not exist!".to_string(), - ) + .await + .wrap_internal_err("fetching OAuth client from database")? + .wrap_request_err_with(|| { + "the specified client does not exist!".to_string() })?; - client.validate_authorized(Some(&user))?; + client + .validate_authorized(Some(&user)) + .wrap_api_err("validating authorized")?; delete_old_images( client.icon_url.clone(), @@ -429,14 +464,16 @@ pub async fn oauth_client_icon_edit( FileHostPublicity::Public, &**file_host, ) - .await?; + .await + .wrap_api_err("deleting old images")?; let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", ) - .await?; + .await + .wrap_api_err("executing `read_limited_from_payload`")?; let upload_result = upload_image_optimized( &format!("data/{client_id}"), FileHostPublicity::Public, @@ -446,9 +483,13 @@ pub async fn oauth_client_icon_edit( Some(1.0), &**file_host, ) - .await?; + .await + .wrap_api_err("uploading image")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let mut editable_client = client.clone(); editable_client.icon_url = Some(upload_result.url); @@ -456,9 +497,15 @@ pub async fn oauth_client_icon_edit( editable_client .update_editable_fields(&mut transaction) - .await?; + .await + .wrap_internal_err( + "updating database records for `oauth_client_icon_edit`", + )?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().body("")) } @@ -486,17 +533,19 @@ pub async fn oauth_client_icon_delete( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let client = DBOAuthClient::get((*client_id).into(), &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified client does not exist!".to_string(), - ) + .await + .wrap_internal_err("fetching OAuth client from database")? + .wrap_request_err_with(|| { + "the specified client does not exist!".to_string() })?; - client.validate_authorized(Some(&user))?; + client + .validate_authorized(Some(&user)) + .wrap_api_err("validating authorized")?; delete_old_images( client.icon_url.clone(), @@ -504,9 +553,13 @@ pub async fn oauth_client_icon_delete( FileHostPublicity::Public, &**file_host, ) - .await?; + .await + .wrap_api_err("deleting old images")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let mut editable_client = client.clone(); editable_client.icon_url = None; @@ -514,8 +567,14 @@ pub async fn oauth_client_icon_delete( editable_client .update_editable_fields(&mut transaction) - .await?; - transaction.commit().await?; + .await + .wrap_internal_err( + "updating database records for `oauth_client_icon_delete`", + )?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().body("")) } @@ -540,14 +599,16 @@ pub async fn get_user_oauth_authorizations( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let authorizations = DBOAuthClientAuthorization::get_all_for_user( current_user.id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching OAuth client authorizations from database")?; let mapped: Vec = authorizations.into_iter().map(|a| a.into()).collect_vec(); @@ -577,7 +638,8 @@ pub async fn revoke_oauth_authorization( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; DBOAuthClientAuthorization::remove( @@ -585,7 +647,8 @@ pub async fn revoke_oauth_authorization( current_user.id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("deleting oauth client authorization from database")?; Ok(HttpResponse::Ok().body("")) } @@ -653,7 +716,9 @@ pub async fn get_clients_inner( pool: web::Data, ) -> Result, ApiError> { let ids: Vec = ids.iter().map(|i| (*i).into()).collect(); - let clients = DBOAuthClient::get_many(&ids, &**pool).await?; + let clients = DBOAuthClient::get_many(&ids, &**pool) + .await + .wrap_internal_err("fetching OAuth clients from database")?; Ok(clients.into_iter().map(|c| c.into()).collect_vec()) } diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 91f9e04b43..3ff46b06e3 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -1,3 +1,4 @@ +use crate::util::error::ApiContext as _; use std::collections::HashMap; use super::ApiError; @@ -18,6 +19,7 @@ use crate::models::v3::user_limits::UserLimits; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::CreateError; use crate::search::SearchState; +use crate::util::error::Context; use crate::util::img::delete_old_images; use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; @@ -65,10 +67,13 @@ pub async fn organization_projects_get( .map(|x| x.1) .ok(); - let organization_data = DBOrganization::get(&id, &**pool, &redis).await?; + let organization_data = DBOrganization::get(&id, &**pool, &redis) + .await + .wrap_internal_err("fetching organization from database")?; if let Some(organization) = organization_data && is_visible_organization(&organization, ¤t_user, &pool, &redis) - .await? + .await + .wrap_api_err("checking organization visibility")? { let project_ids = sqlx::query!( " @@ -81,22 +86,25 @@ pub async fn organization_projects_get( .fetch(&**pool) .map_ok(|m| database::models::DBProjectId(m.id)) .try_collect::>() - .await?; + .await + .wrap_internal_err("fetching project IDs from database")?; let projects_data = crate::database::models::DBProject::get_many_ids( &project_ids, &**pool, &redis, ) - .await?; + .await + .wrap_api_err("fetching organization projects")?; let projects = filter_visible_projects(projects_data, ¤t_user, &pool, true) - .await?; + .await + .wrap_api_err("filtering visible projects")?; Ok(HttpResponse::Ok().json(projects)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -242,20 +250,26 @@ pub async fn organization_get( .ok(); let user_id = current_user.as_ref().map(|x| x.id.into()); - let organization_data = DBOrganization::get(&id, &**pool, &redis).await?; + let organization_data = DBOrganization::get(&id, &**pool, &redis) + .await + .wrap_internal_err("fetching organization from database")?; if let Some(data) = organization_data - && is_visible_organization(&data, ¤t_user, &pool, &redis).await? + && is_visible_organization(&data, ¤t_user, &pool, &redis) + .await + .wrap_api_err("checking organization visibility")? { let members_data = DBTeamMember::get_from_team_full(data.team_id, &**pool, &redis) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; let users = crate::database::models::DBUser::get_many_ids( &members_data.iter().map(|x| x.user_id).collect::>(), &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching users from database")?; let logged_in = current_user .as_ref() .and_then(|user| { @@ -292,12 +306,13 @@ pub async fn organization_get( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching moderation note from database")?; organization.moderation_notes = Some(note.map(Into::into)); } return Ok(HttpResponse::Ok().json(organization)); } - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } #[utoipa::path(tag = "organizations", responses((status = NO_CONTENT)))] @@ -317,25 +332,35 @@ pub async fn organization_notes_edit( &session_queue, Scopes::SESSION_ACCESS, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; if !user.role.is_mod() { - return Err(ApiError::CustomAuthentication( - "you do not have permission to edit moderation notes".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "you do not have permission to edit moderation notes", + ))); } - new_note.validate_not_empty()?; + new_note + .validate_not_empty() + .wrap_api_err("validating not empty")?; let expected_version = - crate::models::moderation_notes::parse_if_match_header(&req)?; + crate::models::moderation_notes::parse_if_match_header(&req) + .wrap_api_err( + "executing `moderation_notes::parse_if_match_header`", + )?; let organization = DBOrganization::get(&info.into_inner().0, &**pool, &redis) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_internal_err("fetching organization from database")? + .wrap_not_found_err("resource not found")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; if let Some(expected) = expected_version { let updated = DBModerationNote::update( None, @@ -346,12 +371,13 @@ pub async fn organization_notes_edit( new_note.user_rating, &mut transaction, ) - .await?; + .await + .wrap_internal_err("updating moderation note in database")?; if updated.is_none() { - return Err(ApiError::PreconditionFailed( - "moderation note version does not match".to_string(), - )); + return Err(ApiError::PreconditionFailed(eyre::eyre!( + "moderation note version does not match", + ))); } } else { let updated = DBModerationNote::insert( @@ -362,17 +388,23 @@ pub async fn organization_notes_edit( new_note.user_rating, &mut transaction, ) - .await?; + .await + .wrap_internal_err("inserting moderation note into database")?; if updated.is_none() { - return Err(ApiError::PreconditionRequired( - "moderation note version does not match".to_string(), - )); + return Err(ApiError::PreconditionRequired(eyre::eyre!( + "moderation note version does not match", + ))); } }; - transaction.commit().await?; - DBModerationNote::clear_organization_cache(organization.id, &redis).await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; + DBModerationNote::clear_organization_cache(organization.id, &redis) + .await + .wrap_internal_err("clearing cached moderation note in Redis")?; Ok(HttpResponse::NoContent().finish()) } @@ -395,9 +427,11 @@ pub async fn organizations_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let ids = serde_json::from_str::>(&ids.ids)?; - let organizations_data = - DBOrganization::get_many(&ids, &**pool, &redis).await?; + let ids = serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing JSON data")?; + let organizations_data = DBOrganization::get_many(&ids, &**pool, &redis) + .await + .wrap_internal_err("fetching organizations from database")?; let team_ids = organizations_data .iter() .map(|x| x.team_id) @@ -405,13 +439,15 @@ pub async fn organizations_get( let teams_data = DBTeamMember::get_from_team_full_many(&team_ids, &**pool, &redis) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; let users = crate::database::models::DBUser::get_many_ids( &teams_data.iter().map(|x| x.user_id).collect::>(), &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching users from database")?; let current_user = get_user_from_headers( &req, @@ -431,7 +467,8 @@ pub async fn organizations_get( &**pool, &redis, ) - .await? + .await + .wrap_internal_err("fetching moderation notes from database")? } else { HashMap::new() }; @@ -444,7 +481,9 @@ pub async fn organizations_get( } for data in organizations_data { - if !is_visible_organization(&data, ¤t_user, &pool, &redis).await? + if !is_visible_organization(&data, ¤t_user, &pool, &redis) + .await + .wrap_api_err("checking organization visibility")? { continue; } @@ -522,16 +561,20 @@ pub async fn organizations_edit( &session_queue, Scopes::ORGANIZATION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - new_organization.validate().map_err(|err| { - ApiError::Validation(validation_errors_to_string(err, None)) - })?; + new_organization + .validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; let string = info.into_inner().0; let result = - database::models::DBOrganization::get(&string, &**pool, &redis).await?; + database::models::DBOrganization::get(&string, &**pool, &redis) + .await + .wrap_internal_err("fetching organization from database")?; if let Some(organization_item) = result { let id = organization_item.id; @@ -540,7 +583,8 @@ pub async fn organizations_edit( user.id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; let permissions = OrganizationPermissions::get_permissions_by_role( &user.role, @@ -548,13 +592,15 @@ pub async fn organizations_edit( ); if let Some(perms) = permissions { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; 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::Auth(eyre::eyre!( + "You do not have the permissions to edit the description of this organization!", + ))); } sqlx::query!( " @@ -566,15 +612,17 @@ pub async fn organizations_edit( id as database::models::ids::DBOrganizationId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `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::Auth(eyre::eyre!( + "You do not have the permissions to edit the name of this organization!", + ))); } sqlx::query!( " @@ -586,15 +634,17 @@ pub async fn organizations_edit( id as database::models::ids::DBOrganizationId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `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::Auth(eyre::eyre!( + "You do not have the permissions to edit the slug of this organization!", + ))); } let existing = DBOrganization::get( @@ -602,12 +652,12 @@ pub async fn organizations_edit( &mut transaction, &redis, ) - .await?; + .await + .wrap_internal_err("fetching organization from database")?; if existing.is_some() { - return Err(ApiError::InvalidInput( - "Slug collides with other organization's id!" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Slug collides with other organization's id!", + ))); } // Make sure the new name is different from the old one @@ -625,13 +675,15 @@ pub async fn organizations_edit( slug ) .fetch_one(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `organizations_edit`", + )?; if results.exists.unwrap_or(true) { - return Err(ApiError::InvalidInput( - "Slug collides with other organization's id!" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Slug collides with other organization's id!", + ))); } } @@ -645,26 +697,32 @@ pub async fn organizations_edit( id as database::models::ids::DBOrganizationId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `organizations_edit`", + )?; } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; database::models::DBOrganization::clear_cache( organization_item.id, Some(organization_item.slug), &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::CustomAuthentication( - "You do not have permission to edit this organization!" - .to_string(), - )) + Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to edit this organization!", + ))) } } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -685,17 +743,17 @@ pub async fn organization_delete( &session_queue, Scopes::ORGANIZATION_DELETE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .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(), - ) + .await + .wrap_internal_err("fetching organization from database")? + .wrap_request_err_with(|| { + "the specified organization does not exist!".to_string() })?; if !user.role.is_admin() { @@ -707,11 +765,9 @@ pub async fn organization_delete( &**pool, ) .await - .map_err(ApiError::Database)? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified organization does not exist!".to_string(), - ) + .wrap_internal_err("fetching organization team member")? + .wrap_request_err_with(|| { + "the specified organization does not exist!".to_string() })?; let permissions = OrganizationPermissions::get_permissions_by_role( @@ -721,10 +777,9 @@ 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::Auth(eyre::eyre!( + "You don't have permission to delete this organization!", + ))); } } @@ -736,11 +791,15 @@ pub async fn organization_delete( organization.team_id as database::models::ids::DBTeamId ) .fetch_one(&**pool) - .await? + .await + .wrap_internal_err("fetching owner ID from database")? .user_id; let owner_id = database::models::ids::DBUserId(owner_id); - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; // Handle projects- every project that is in this organization needs to have its owner changed the organization owner // Now, no project should have an owner if it is in an organization, and also @@ -762,7 +821,8 @@ pub async fn organization_delete( ) }) .try_collect::>() - .await?; + .await + .wrap_internal_err("querying database for `organization_delete`")?; let organization_project_teams = organization_projects .iter() .map(|(team_id, _)| *team_id) @@ -776,7 +836,8 @@ pub async fn organization_delete( let new_id = crate::database::models::ids::generate_team_member_id( &mut transaction, ) - .await?; + .await + .wrap_internal_err("fetching query results from database")?; let member = DBTeamMember { id: new_id, team_id: *organization_project_team, @@ -789,7 +850,9 @@ pub async fn organization_delete( payouts_split: Decimal::ZERO, ordering: 0, }; - member.insert(&mut transaction).await?; + member.insert(&mut transaction).await.wrap_internal_err( + "inserting database records for `organization_delete`", + )?; } // Safely remove the organization let result = database::models::DBOrganization::remove( @@ -797,19 +860,26 @@ pub async fn organization_delete( &mut transaction, &redis, ) - .await?; + .await + .wrap_internal_err("fetching query results from database")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; database::models::DBOrganization::clear_cache( organization.id, Some(organization.slug), &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; for team_id in &organization_project_teams { - database::models::DBTeamMember::clear_cache(*team_id, &redis).await?; + database::models::DBTeamMember::clear_cache(*team_id, &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; } for project_id in organization_project_ids { @@ -820,18 +890,22 @@ pub async fn organization_delete( None, None, ) - .await?; + .await + .wrap_api_err( + "executing `projects::clear_project_cache_and_queue_search`", + )?; } if !organization_project_teams.is_empty() { database::models::DBUser::clear_project_cache(&[owner_id], &redis) - .await?; + .await + .wrap_internal_err("clearing cached user in Redis")?; } if result.is_some() { Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -858,16 +932,16 @@ pub async fn organization_projects_add( &session_queue, Scopes::PROJECT_WRITE | Scopes::ORGANIZATION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let organization = database::models::DBOrganization::get(&info, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified organization does not exist!".to_string(), - ) + .await + .wrap_internal_err("fetching organization from database")? + .wrap_request_err_with(|| { + "the specified organization does not exist!".to_string() })?; let project_item = database::models::DBProject::get( @@ -875,17 +949,15 @@ pub async fn organization_projects_add( &**pool, &redis, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified project does not exist!".to_string(), - ) + .await + .wrap_api_err("fetching project from database")? + .wrap_request_err_with(|| { + "the specified project does not exist!".to_string() })?; if project_item.inner.organization_id.is_some() { - return Err(ApiError::InvalidInput( - "The specified project is already owned by an organization!" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "The specified project is already owned by an organization!", + ))); } let project_team_member = @@ -895,11 +967,10 @@ pub async fn organization_projects_add( false, &**pool, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "You are not a member of this project!".to_string(), - ) + .await + .wrap_internal_err("fetching team member from database")? + .wrap_request_err_with(|| { + "you are not a member of this project!".to_string() })?; let organization_team_member = database::models::DBTeamMember::get_from_user_id_organization( @@ -908,18 +979,17 @@ pub async fn organization_projects_add( false, &**pool, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "You are not a member of this organization!".to_string(), - ) + .await + .wrap_internal_err("fetching team member from database")? + .wrap_request_err_with(|| { + "you are not a member of this organization!".to_string() })?; // Require ownership of a project to add it to an organization if !current_user.role.is_admin() && !project_team_member.is_owner { - return Err(ApiError::CustomAuthentication( - "You need to be an owner of a project to add it to an organization!".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You need to be an owner of a project to add it to an organization!", + ))); } let permissions = OrganizationPermissions::get_permissions_by_role( @@ -928,7 +998,10 @@ pub async fn organization_projects_add( ) .unwrap_or_default(); if permissions.contains(OrganizationPermissions::ADD_PROJECT) { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " UPDATE mods @@ -939,7 +1012,10 @@ pub async fn organization_projects_add( project_item.inner.id as database::models::ids::DBProjectId ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `organization_projects_add`", + )?; // The former owner is no longer an owner (as it is now 'owned' by the organization, 'given' to them) // The former owner is still a member of the project, but not an owner @@ -955,7 +1031,10 @@ pub async fn organization_projects_add( organization.team_id as database::models::ids::DBTeamId ) .fetch_one(&mut transaction) - .await?; + .await + .wrap_internal_err( + "fetching organization owner user ID from database", + )?; let organization_owner_user_id = database::models::ids::DBUserId(organization_owner_user_id.id); @@ -968,20 +1047,28 @@ pub async fn organization_projects_add( organization_owner_user_id as database::models::ids::DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `organization_projects_add`", + )?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; database::models::DBUser::clear_project_cache( &[current_user.id.into()], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached user in Redis")?; database::models::DBTeamMember::clear_cache( project_item.inner.team_id, &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; super::projects::clear_project_cache_and_queue_search( &redis, &search_state, @@ -989,12 +1076,14 @@ pub async fn organization_projects_add( project_item.inner.slug, None, ) - .await?; + .await + .wrap_api_err( + "executing `projects::clear_project_cache_and_queue_search`", + )?; } else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to add projects to this organization!" - .to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to add projects to this organization!", + ))); } Ok(HttpResponse::Ok().finish()) } @@ -1025,7 +1114,8 @@ pub async fn organization_projects_remove( &session_queue, Scopes::PROJECT_WRITE | Scopes::ORGANIZATION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let organization = database::models::DBOrganization::get( @@ -1033,20 +1123,18 @@ pub async fn organization_projects_remove( &**pool, &redis, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified organization does not exist!".to_string(), - ) + .await + .wrap_internal_err("fetching organization from database")? + .wrap_request_err_with(|| { + "the specified organization does not exist!".to_string() })?; let project_item = database::models::DBProject::get(&project_id, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified project does not exist!".to_string(), - ) + .await + .wrap_api_err("fetching project from database")? + .wrap_request_err_with(|| { + "the specified project does not exist!".to_string() })?; if !project_item @@ -1054,10 +1142,9 @@ pub async fn organization_projects_remove( .organization_id .eq(&Some(organization.id)) { - return Err(ApiError::InvalidInput( - "The specified project is not owned by this organization!" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "The specified project is not owned by this organization!", + ))); } let organization_team_member = @@ -1067,11 +1154,10 @@ pub async fn organization_projects_remove( false, &**pool, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "You are not a member of this organization!".to_string(), - ) + .await + .wrap_internal_err("fetching team member from database")? + .wrap_request_err_with(|| { + "you are not a member of this organization!".to_string() })?; let permissions = OrganizationPermissions::get_permissions_by_role( @@ -1087,12 +1173,11 @@ pub async fn organization_projects_remove( false, &**pool, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified user is not a member of this organization!" - .to_string(), - ) + .await + .wrap_internal_err("deleting team member from database")? + .wrap_request_err_with(|| { + "the specified user is not a member of this organization!" + .to_string() })?; // Then, we get the team member of the project and that user (if it exists) @@ -1104,9 +1189,13 @@ pub async fn organization_projects_remove( true, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; // If the user is not a member of the project, we add them let new_owner = match new_owner { @@ -1116,7 +1205,8 @@ pub async fn organization_projects_remove( crate::database::models::ids::generate_team_member_id( &mut transaction, ) - .await?; + .await + .wrap_internal_err("generating team member ID")?; let member = DBTeamMember { id: new_id, team_id: project_item.inner.team_id, @@ -1129,7 +1219,10 @@ pub async fn organization_projects_remove( payouts_split: Decimal::ZERO, ordering: 0, }; - member.insert(&mut transaction).await?; + member + .insert(&mut transaction) + .await + .wrap_internal_err("inserting database records for `organization_projects_remove`")?; member } }; @@ -1150,7 +1243,10 @@ pub async fn organization_projects_remove( ProjectPermissions::all().bits() as i64 ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `organization_projects_remove`", + )?; sqlx::query!( " @@ -1161,19 +1257,27 @@ pub async fn organization_projects_remove( project_item.inner.id as database::models::ids::DBProjectId ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `organization_projects_remove`", + )?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; database::models::DBUser::clear_project_cache( &[current_user.id.into()], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached user in Redis")?; database::models::DBTeamMember::clear_cache( project_item.inner.team_id, &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; super::projects::clear_project_cache_and_queue_search( &redis, &search_state, @@ -1181,12 +1285,14 @@ pub async fn organization_projects_remove( project_item.inner.slug, None, ) - .await?; + .await + .wrap_api_err( + "executing `projects::clear_project_cache_and_queue_search`", + )?; } else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to add projects to this organization!" - .to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to add projects to this organization!", + ))); } Ok(HttpResponse::Ok().finish()) } @@ -1221,17 +1327,17 @@ pub async fn organization_icon_edit( &session_queue, Scopes::ORGANIZATION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .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(), - ) + .await + .wrap_internal_err("fetching organization from database")? + .wrap_request_err_with(|| { + "the specified organization does not exist!".to_string() })?; if !user.role.is_mod() { @@ -1241,7 +1347,7 @@ pub async fn organization_icon_edit( &**pool, ) .await - .map_err(ApiError::Database)?; + .wrap_internal_err("fetching organization team member")?; let permissions = OrganizationPermissions::get_permissions_by_role( &user.role, @@ -1250,10 +1356,9 @@ 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::Auth(eyre::eyre!( + "You don't have permission to edit this organization's icon.", + ))); } } @@ -1263,14 +1368,16 @@ pub async fn organization_icon_edit( FileHostPublicity::Public, &**file_host, ) - .await?; + .await + .wrap_api_err("deleting old images")?; let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", ) - .await?; + .await + .wrap_api_err("executing `read_limited_from_payload`")?; let organization_id: OrganizationId = organization_item.id.into(); let upload_result = crate::util::img::upload_image_optimized( @@ -1282,9 +1389,13 @@ pub async fn organization_icon_edit( Some(1.0), &**file_host, ) - .await?; + .await + .wrap_api_err("uploading image")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " @@ -1298,15 +1409,20 @@ pub async fn organization_icon_edit( organization_item.id as database::models::ids::DBOrganizationId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `organization_icon_edit`")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; database::models::DBOrganization::clear_cache( organization_item.id, Some(organization_item.slug), &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::NoContent().body("")) } @@ -1328,17 +1444,17 @@ pub async fn delete_organization_icon( &session_queue, Scopes::ORGANIZATION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .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(), - ) + .await + .wrap_internal_err("fetching organization from database")? + .wrap_request_err_with(|| { + "the specified organization does not exist!".to_string() })?; if !user.role.is_mod() { @@ -1348,7 +1464,7 @@ pub async fn delete_organization_icon( &**pool, ) .await - .map_err(ApiError::Database)?; + .wrap_internal_err("fetching organization team member")?; let permissions = OrganizationPermissions::get_permissions_by_role( &user.role, @@ -1357,10 +1473,9 @@ 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::Auth(eyre::eyre!( + "You don't have permission to edit this organization's icon.", + ))); } } @@ -1370,9 +1485,13 @@ pub async fn delete_organization_icon( FileHostPublicity::Public, &**file_host, ) - .await?; + .await + .wrap_api_err("deleting old images")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " @@ -1383,16 +1502,21 @@ pub async fn delete_organization_icon( organization_item.id as database::models::ids::DBOrganizationId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `delete_organization_icon`")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; database::models::DBOrganization::clear_cache( organization_item.id, Some(organization_item.slug), &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::NoContent().body("")) } diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs index 8f9cb8a447..b0deabbb83 100644 --- a/apps/labrinth/src/routes/v3/payouts.rs +++ b/apps/labrinth/src/routes/v3/payouts.rs @@ -14,6 +14,7 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::routes::internal::globals::tax_compliance_payout_threshold; use crate::util::avalara1099; +use crate::util::error::ApiContext as _; use crate::util::error::Context; use crate::util::gotenberg::GotenbergClient; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; @@ -70,16 +71,21 @@ pub async fn post_compliance_form( &session_queue, Scopes::PAYOUTS_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let user_id = DBUserId(user.id.0 as i64); - let mut txn = pool.begin().await?; + let mut txn = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let maybe_compliance = users_compliance::UserCompliance::get_by_user_id(&mut txn, user_id) - .await?; + .await + .wrap_internal_err("fetching user compliance from Redis")?; let mut compliance = match maybe_compliance { Some(c) => { @@ -87,9 +93,9 @@ pub async fn post_compliance_form( && c.form_type.is_some_and(|f| f.requires_domestic_tin_match()) && !c.tin_matched { - return Err(ApiError::InvalidInput( + return Err(ApiError::Request(eyre::eyre!( "Your TIN/SSN did not match the IRS records. Please contact support https://support.modrinth.com".to_owned(), - )); + ))); } c @@ -109,7 +115,9 @@ pub async fn post_compliance_form( }, }; - let result = avalara1099::request_form(user_id, body.0.form_type).await?; + let result = avalara1099::request_form(user_id, body.0.form_type) + .await + .wrap_api_err("executing `avalara1099::request_form`")?; match result { Ok( @@ -132,15 +140,20 @@ pub async fn post_compliance_form( compliance.form_type = Some(body.0.form_type); compliance.last_checked = Utc::now() - COMPLIANCE_CHECK_DEBOUNCE; - compliance.upsert_partial(&mut txn).await?; - txn.commit().await?; + compliance + .upsert_partial(&mut txn) + .await + .wrap_internal_err("updating user compliance request")?; + txn.commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::Ok().json(toplevel)) } Ok(_) => { error!("Missing form request ID in Avalara response"); - Err(ApiError::TaxComplianceApi) + Err(ApiError::Internal(eyre::eyre!("tax compliance API failed"))) } Err(json_error) => { @@ -148,7 +161,7 @@ pub async fn post_compliance_form( "Error sending request to Avalara: {}", serde_json::to_string_pretty(&json_error).unwrap() ); - Err(ApiError::TaxComplianceApi) + Err(ApiError::Internal(eyre::eyre!("tax compliance API failed"))) } } } @@ -171,37 +184,27 @@ pub async fn paypal_webhook( .headers() .get("PAYPAL-AUTH-ALGO") .and_then(|x| x.to_str().ok()) - .ok_or_else(|| { - ApiError::InvalidInput("missing auth algo".to_string()) - })?; + .wrap_request_err_with(|| "missing auth algo".to_string())?; let cert_url = req .headers() .get("PAYPAL-CERT-URL") .and_then(|x| x.to_str().ok()) - .ok_or_else(|| { - ApiError::InvalidInput("missing cert url".to_string()) - })?; + .wrap_request_err_with(|| "missing cert url".to_string())?; let transmission_id = req .headers() .get("PAYPAL-TRANSMISSION-ID") .and_then(|x| x.to_str().ok()) - .ok_or_else(|| { - ApiError::InvalidInput("missing transmission ID".to_string()) - })?; + .wrap_request_err_with(|| "missing transmission ID".to_string())?; let transmission_sig = req .headers() .get("PAYPAL-TRANSMISSION-SIG") .and_then(|x| x.to_str().ok()) - .ok_or_else(|| { - ApiError::InvalidInput("missing transmission sig".to_string()) - })?; + .wrap_request_err_with(|| "missing transmission sig".to_string())?; let transmission_time = req .headers() .get("PAYPAL-TRANSMISSION-TIME") .and_then(|x| x.to_str().ok()) - .ok_or_else(|| { - ApiError::InvalidInput("missing transmission time".to_string()) - })?; + .wrap_request_err_with(|| "missing transmission time".to_string())?; #[derive(Deserialize)] struct WebHookResponse { @@ -228,12 +231,13 @@ pub async fn paypal_webhook( )), None, ) - .await?; + .await + .wrap_api_err("verifying PayPal webhook signature")?; if &webhook_res.verification_status != "SUCCESS" { - return Err(ApiError::InvalidInput( - "Invalid webhook signature".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Invalid webhook signature", + ))); } #[derive(Deserialize)] @@ -247,7 +251,8 @@ pub async fn paypal_webhook( pub resource: PayPalResource, } - let webhook = serde_json::from_str::(&body)?; + let webhook = serde_json::from_str::(&body) + .wrap_request_err("deserializing JSON data")?; match &*webhook.event_type { "PAYMENT.PAYOUTS-ITEM.BLOCKED" @@ -255,7 +260,10 @@ pub async fn paypal_webhook( | "PAYMENT.PAYOUTS-ITEM.REFUNDED" | "PAYMENT.PAYOUTS-ITEM.RETURNED" | "PAYMENT.PAYOUTS-ITEM.CANCELED" => { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let result = sqlx::query!( "SELECT user_id, amount, fee FROM payouts WHERE platform_id = $1 AND status = $2", @@ -263,7 +271,7 @@ pub async fn paypal_webhook( PayoutStatus::InTransit.as_str() ) .fetch_optional(&mut transaction) - .await?; + .await.wrap_internal_err("querying database for `paypal_webhook`")?; if let Some(result) = result { sqlx::query!( @@ -281,9 +289,13 @@ pub async fn paypal_webhook( webhook.resource.payout_item_id ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `paypal_webhook`")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; crate::database::models::user_item::DBUser::clear_caches( &[( @@ -292,11 +304,15 @@ pub async fn paypal_webhook( )], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; } } "PAYMENT.PAYOUTS-ITEM.SUCCEEDED" => { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " UPDATE payouts @@ -307,8 +323,12 @@ pub async fn paypal_webhook( webhook.resource.payout_item_id ) .execute(&mut transaction) - .await?; - transaction.commit().await?; + .await + .wrap_internal_err("querying database for `paypal_webhook`")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; } _ => {} } @@ -334,21 +354,21 @@ pub async fn tremendous_webhook( .get("Tremendous-Webhook-Signature") .and_then(|x| x.to_str().ok()) .and_then(|x| x.split('=').next_back()) - .ok_or_else(|| { - ApiError::InvalidInput("missing webhook signature".to_string()) - })?; - - let mut mac: Hmac = Hmac::new_from_slice( - ENV.TREMENDOUS_PRIVATE_KEY.as_bytes(), - ) - .map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?; + .wrap_request_err_with(|| "missing webhook signature".to_string())?; + + let mut mac: Hmac = + Hmac::new_from_slice(ENV.TREMENDOUS_PRIVATE_KEY.as_bytes()) + .map_err(|err| eyre::eyre!(err)) + .wrap_failed_dependency_err( + "error initializing HMAC".to_string(), + )?; mac.update(body.as_bytes()); let request_signature = mac.finalize().into_bytes().encode_hex::(); if &*request_signature != signature { - return Err(ApiError::InvalidInput( - "Invalid webhook signature".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Invalid webhook signature", + ))); } #[derive(Deserialize)] @@ -367,11 +387,15 @@ pub async fn tremendous_webhook( pub payload: TremendousPayload, } - let webhook = serde_json::from_str::(&body)?; + let webhook = serde_json::from_str::(&body) + .wrap_request_err("deserializing JSON data")?; match &*webhook.event { "REWARDS.CANCELED" | "REWARDS.DELIVERY.FAILED" => { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let result = sqlx::query!( "SELECT user_id, amount, fee FROM payouts WHERE platform_id = $1 AND status = $2", @@ -379,7 +403,7 @@ pub async fn tremendous_webhook( PayoutStatus::InTransit.as_str() ) .fetch_optional(&mut transaction) - .await?; + .await.wrap_internal_err("querying database for `tremendous_webhook`")?; if let Some(result) = result { sqlx::query!( @@ -397,9 +421,15 @@ pub async fn tremendous_webhook( webhook.payload.resource.id ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `tremendous_webhook`", + )?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; crate::database::models::user_item::DBUser::clear_caches( &[( @@ -408,11 +438,15 @@ pub async fn tremendous_webhook( )], &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; } } "REWARDS.DELIVERY.SUCCEEDED" => { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " UPDATE payouts @@ -423,8 +457,12 @@ pub async fn tremendous_webhook( webhook.payload.resource.id ) .execute(&mut transaction) - .await?; - transaction.commit().await?; + .await + .wrap_internal_err("querying database for `tremendous_webhook`")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; } _ => {} } @@ -463,12 +501,14 @@ pub async fn calculate_fees( &session_queue, false, ) - .await? - .ok_or_else(|| { - ApiError::Authentication(AuthenticationError::InvalidCredentials) - })?; + .await + .wrap_auth_err("authenticating API request")? + .wrap_auth_err_with(|| AuthenticationError::InvalidCredentials)?; - let payout_flow = payouts_queue.create_payout_flow(body.0).await?; + let payout_flow = payouts_queue + .create_payout_flow(body.0) + .await + .wrap_api_err("creating payout flow")?; Ok(web::Json(WithdrawalFees { net_usd: payout_flow.net_usd, @@ -500,18 +540,20 @@ pub async fn create_payout( &session_queue, false, ) - .await? - .ok_or_else(|| { - ApiError::Authentication(AuthenticationError::InvalidCredentials) - })?; + .await + .wrap_auth_err("authenticating API request")? + .wrap_auth_err_with(|| AuthenticationError::InvalidCredentials)?; if !scopes.contains(Scopes::PAYOUTS_WRITE) { - return Err(ApiError::Authentication( + return Err(ApiError::Auth(eyre::eyre!( AuthenticationError::InvalidCredentials, - )); + ))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " @@ -528,8 +570,8 @@ pub async fn create_payout( .wrap_internal_err("failed to calculate user balance")?; if body.amount < Decimal::ZERO { - return Err(ApiError::InvalidInput( - "Amount must be positive!".to_string(), + return Err(ApiError::Request( + eyre::eyre!("Amount must be positive!",), )); } @@ -537,13 +579,18 @@ pub async fn create_payout( // for tax threshold checks. body.amount may be in local currency for // gift cards (e.g. INR), so we must not compare it directly against // USD thresholds. - let payout_flow = payouts_queue.create_payout_flow(body.0).await?; + let payout_flow = payouts_queue + .create_payout_flow(body.0) + .await + .wrap_api_err("creating payout flow")?; let amount_usd = payout_flow.net_usd.get(); let requires_manual_review; if let Some(threshold) = tax_compliance_payout_threshold() { - let maybe_compliance = update_compliance_status(&pool, user.id).await?; + let maybe_compliance = update_compliance_status(&pool, user.id) + .await + .wrap_api_err("updating compliance status")?; let (tin_matched, signed, requested, api_check_failed) = match maybe_compliance { @@ -575,14 +622,16 @@ pub async fn create_payout( // error with the API, as this is more accurate than saying the form wasn't completed // properly as this might be wrong! if api_check_failed { - return Err(ApiError::TaxComplianceApi); + return Err(ApiError::Internal(eyre::eyre!( + "tax compliance API failed" + ))); } - return Err(ApiError::InvalidInput(match (tin_matched, signed, requested) { + return Err(ApiError::Request(eyre::eyre!(match (tin_matched, signed, requested) { (_, false, true) => "Tax form isn't signed yet!", (false, true, true) => "Tax form is signed, but the Tax Identification Number/SSN didn't match the IRS records. Withdrawals are blocked until the TIN/SSN matches.", _ => "Tax compliance form is required to withdraw more!", - }.to_owned())); + }.to_owned()))); } } else { requires_manual_review = None; @@ -592,19 +641,22 @@ pub async fn create_payout( r } else { users_compliance::UserCompliance::get_by_user_id(&**pool, user.id) - .await? + .await + .wrap_internal_err("fetching user compliance from database")? .is_some_and(|x| x.requires_manual_review) }; if requires_manual_review { - return Err(ApiError::InvalidInput( - "More information is required to proceed. Please contact support (https://support.modrinth.com, support@modrinth.com)".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "More information is required to proceed. Please contact support (https://support.modrinth.com, support@modrinth.com)", + ))); } let payout_flow = match payout_flow.validate(balance.available) { Ok(flow) => flow, - Err(err) => return Err(ApiError::InvalidInput(err.to_string())), + Err(err) => { + return Err(ApiError::Request(eyre::eyre!("{err}"))); + } }; let payout_id = generate_payout_id(&mut transaction) @@ -613,7 +665,8 @@ pub async fn create_payout( payout_flow .execute(&payouts_queue, &user, payout_id, transaction, &gotenberg) - .await?; + .await + .wrap_api_err("executing `execute`")?; crate::database::models::DBUser::clear_caches(&[(user.id, None)], &redis) .await @@ -714,19 +767,22 @@ pub async fn transaction_history( &session_queue, Scopes::PAYOUTS_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let payout_ids = crate::database::models::payout_item::DBPayout::get_all_for_user( user.id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching payouts from database")?; let payouts = crate::database::models::payout_item::DBPayout::get_many( &payout_ids, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching payouts from database")?; let withdrawals = payouts .into_iter() @@ -802,13 +858,15 @@ pub async fn cancel_payout( &session_queue, Scopes::PAYOUTS_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let id = info.into_inner().0; let payout = crate::database::models::payout_item::DBPayout::get(id.into(), &**pool) - .await?; + .await + .wrap_internal_err("fetching payout from database")?; if let Some(payout) = payout { if payout.user_id != user.id.into() && !user.role.is_admin() { @@ -818,9 +876,9 @@ pub async fn cancel_payout( if let Some(platform_id) = payout.platform_id { if let Some(method) = payout.method { if payout.status != PayoutStatus::InTransit { - return Err(ApiError::InvalidInput( - "Payout cannot be cancelled!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Payout cannot be cancelled!", + ))); } match method { @@ -835,7 +893,8 @@ pub async fn cancel_payout( None, None, ) - .await?; + .await + .wrap_api_err("cancelling PayPal payout")?; } PayoutMethodType::Tremendous => { payouts @@ -844,7 +903,8 @@ pub async fn cancel_payout( &format!("rewards/{platform_id}/cancel"), None, ) - .await?; + .await + .wrap_api_err("cancelling Tremendous payout")?; } PayoutMethodType::MuralPay => { let payout_request_id = platform_id @@ -859,7 +919,10 @@ pub async fn cancel_payout( } } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " UPDATE payouts @@ -870,19 +933,23 @@ pub async fn cancel_payout( platform_id ) .execute(&mut transaction) - .await?; - transaction.commit().await?; + .await + .wrap_internal_err("querying database for `cancel_payout`")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().finish()) } else { - Err(ApiError::InvalidInput( - "Payout cannot be cancelled!".to_string(), - )) + Err(ApiError::Request(eyre::eyre!( + "Payout cannot be cancelled!", + ))) } } else { - Err(ApiError::InvalidInput( - "Payout cannot be cancelled!".to_string(), - )) + Err(ApiError::Request(eyre::eyre!( + "Payout cannot be cancelled!", + ))) } } else { Ok(HttpResponse::NotFound().finish()) @@ -920,7 +987,8 @@ pub async fn payment_methods( ) -> Result { let methods = payouts_queue .get_payout_methods() - .await? + .await + .wrap_api_err("fetching payout methods")? .into_iter() .filter(|x| { let mut val = true; @@ -973,10 +1041,13 @@ pub async fn get_balance( &session_queue, Scopes::PAYOUTS_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - let balance = get_user_balance(user.id.into(), &pool).await?; + let balance = get_user_balance(user.id.into(), &pool) + .await + .wrap_api_err("fetching user balance")?; let mut requested_form_type = None; let mut form_completion_status = None; @@ -985,7 +1056,8 @@ pub async fn get_balance( if tax_compliance_payout_threshold().is_some() { form_completion_status = Some( update_compliance_status(&pool, user.id.into()) - .await? + .await + .wrap_api_err("updating compliance status")? .filter(|x| x.model.form_type.is_some()) .map_or(FormCompletionStatus::Unrequested, |compliance| { requested_form_type = compliance.model.form_type; @@ -1031,7 +1103,8 @@ async fn get_user_balance( user_id.0 ) .fetch_all(pool) - .await?; + .await + .wrap_internal_err("fetching payouts from database")?; let available = payouts .iter() @@ -1054,7 +1127,7 @@ async fn get_user_balance( user_id.0 ) .fetch_optional(pool) - .await?; + .await.wrap_internal_err("fetching withdrawn from database")?; let (withdrawn, fees, withdrawn_this_year) = withdrawn.map_or((Decimal::ZERO, Decimal::ZERO, Decimal::ZERO), |x| { @@ -1152,7 +1225,9 @@ async fn update_compliance_status( } } - compliance.update(pg).await?; + compliance.update(pg).await.wrap_internal_err( + "updating database records for `update_compliance_status`", + )?; Ok(Some(ComplianceCheck { model: compliance, @@ -1202,7 +1277,8 @@ pub async fn platform_revenue( ", ) .fetch_optional(&**pool) - .await? + .await + .wrap_internal_err("fetching all time payouts from database")? .and_then(|x| x.sum) .unwrap_or(Decimal::ZERO); @@ -1212,7 +1288,8 @@ pub async fn platform_revenue( ", ) .fetch_optional(&**pool) - .await? + .await + .wrap_internal_err("fetching all available from database")? .and_then(|x| x.sum) .unwrap_or(Decimal::ZERO); @@ -1232,7 +1309,8 @@ pub async fn platform_revenue( end ) .fetch_all(&**pool) - .await? + .await + .wrap_internal_err("fetching revenue from database")? .into_iter() .map(|x| RevenueData { time: x.created.timestamp() as u64, diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index f7c383a8ca..e8f5adc972 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -99,25 +99,14 @@ pub enum CreateError { impl From for CreateError { fn from(value: crate::routes::ApiError) -> Self { match value { - crate::routes::ApiError::Database(err) => Self::DatabaseError(err), - crate::routes::ApiError::SqlxDatabase(err) => { - Self::SqlxDatabaseError(err) - } - crate::routes::ApiError::Authentication(err) => { - Self::Unauthorized(err) - } - crate::routes::ApiError::CustomAuthentication(err) => { - Self::CustomAuthenticationError(err) - } crate::routes::ApiError::Auth(err) => { Self::CustomAuthenticationError(format!("{err:#}")) } - crate::routes::ApiError::InvalidInput(err) - | crate::routes::ApiError::Validation(err) => { - Self::InvalidInput(err) + crate::routes::ApiError::Request(err) => { + Self::InvalidInput(format!("{err:#}")) } err => Self::DatabaseError(models::DatabaseError::SchemaError( - err.to_string(), + format!("{err:#}"), )), } } diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index 5112807803..2922375796 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -138,13 +138,13 @@ pub async fn create( Scopes::PROJECT_CREATE, ) .await - .map_err(ApiError::from)?; + .wrap_auth_err("authenticating project creator")?; require_verified_email(&user)?; let limits = UserLimits::get_for_projects(&user, &db) .await - .map_err(ApiError::from)?; + .wrap_internal_err("fetching project limits")?; if limits.current >= limits.max { return Err(CreateError::LimitReached); } diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 8c7074a4ba..4af79d0635 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -1,3 +1,4 @@ +use crate::util::error::ApiContext as _; use std::any::type_name; use std::cmp::Reverse; use std::collections::HashMap; @@ -34,7 +35,6 @@ use crate::util::error::Context; use crate::util::img; use crate::util::img::{delete_old_images, upload_image_optimized}; use crate::util::routes::read_limited_from_payload; -use crate::util::validate::validation_errors_to_string; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use chrono::Utc; use eyre::eyre; @@ -84,7 +84,8 @@ pub async fn clear_project_cache_and_queue_search( clear_dependencies, redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; search_state .queue @@ -119,9 +120,10 @@ pub async fn random_projects_get( pool: web::Data, redis: web::Data, ) -> Result { - count.validate().map_err(|err| { - ApiError::Validation(validation_errors_to_string(err, None)) - })?; + count + .validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; let project_ids = sqlx::query!( // IDs are randomly generated (see the `generate_ids` macro), so fetching a @@ -143,11 +145,12 @@ pub async fn random_projects_get( .fetch(&**pool) .map_ok(|m| db_ids::DBProjectId(m.id)) .try_collect::>() - .await?; + .await.wrap_internal_err("querying random project IDs")?; let projects_data = db_models::DBProject::get_many_ids(&project_ids, &**pool, &redis) - .await? + .await + .wrap_api_err("fetching projects by ID")? .into_iter() .map(Project::from) .collect::>(); @@ -188,9 +191,11 @@ pub async fn projects_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let ids = serde_json::from_str::>(&ids.ids)?; - let projects_data = - db_models::DBProject::get_many(&ids, &**pool, &redis).await?; + let ids = serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing JSON data")?; + let projects_data = db_models::DBProject::get_many(&ids, &**pool, &redis) + .await + .wrap_api_err("fetching requested projects")?; let user_option = get_user_from_headers( &req, @@ -205,7 +210,8 @@ pub async fn projects_get( let projects = filter_visible_projects(projects_data, &user_option, &pool, false) - .await?; + .await + .wrap_api_err("filtering visible projects")?; Ok(HttpResponse::Ok().json(projects)) } @@ -256,7 +262,7 @@ pub async fn project_get_internal( { return Ok(web::Json(Project::from(data))); } - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } #[derive(Debug, Serialize, Deserialize, Validate, utoipa::ToSchema)] @@ -388,18 +394,21 @@ pub async fn project_edit_internal( &session_queue, Scopes::PROJECT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - new_project.validate().map_err(|err| { - ApiError::Validation(validation_errors_to_string(err, None)) - })?; + new_project + .validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; let Some(mut project_item) = db_models::DBProject::get(&info.into_inner().0, &**pool, &redis) - .await? + .await + .wrap_api_err("fetching project")? else { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); }; let id = project_item.inner.id; @@ -410,26 +419,29 @@ pub async fn project_edit_internal( user.id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; let Some(perms) = ProjectPermissions::get_permissions_by_role( &user.role, &team_member, &organization_team_member, ) else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to edit this project!".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to edit this project!", + ))); }; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; 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::Auth(eyre::eyre!( + "You do not have the permissions to edit the name of this project!", + ))); } sqlx::query!( @@ -442,15 +454,15 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_edit_internal`")?; } 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::Auth(eyre::eyre!( + "You do not have the permissions to edit the summary of this project!", + ))); } sqlx::query!( @@ -463,15 +475,15 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_edit_internal`")?; } 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::Auth(eyre::eyre!( + "You do not have the permissions to edit the status of this project!", + ))); } if !(user.role.is_mod() @@ -480,9 +492,9 @@ pub async fn project_edit_internal( || 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::Auth(eyre::eyre!( + "You don't have permission to set this status!", + ))); } // If a moderator (non-admin) is completing a review while another moderator holds an @@ -493,21 +505,24 @@ pub async fn project_edit_internal( && status != &ProjectStatus::Processing && let Some(lock) = DBModerationLock::get_with_user(project_item.inner.id, &pool) - .await? + .await + .wrap_internal_err( + "fetching moderation lock from database", + )? && lock.moderator_id != db_ids::DBUserId::from(user.id) && !lock.expired { - return Err(ApiError::CustomAuthentication(format!( + return Err(ApiError::Auth(eyre::eyre!(format!( "This project is currently being moderated by @{}. Please wait for them to finish or for the lock to expire.", lock.moderator_username - ))); + )))); } if status == &ProjectStatus::Processing { if project_item.versions.is_empty() { - return Err(ApiError::InvalidInput(String::from( + return Err(ApiError::Request(eyre::eyre!(String::from( "Project submitted for review with no initial versions", - ))); + )))); } sqlx::query!( @@ -519,7 +534,7 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await.wrap_internal_err("querying database for `project_edit_internal`")?; } if status.is_approved() && !project_item.inner.status.is_approved() { @@ -532,7 +547,10 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `project_edit_internal`", + )?; } if status.is_searchable() @@ -559,7 +577,10 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `project_edit_internal`", + )?; } if user.role.is_mod() && !ENV.MODERATION_SLACK_WEBHOOK.is_empty() { @@ -596,7 +617,8 @@ pub async fn project_edit_internal( .fetch(&mut transaction) .map_ok(|c| db_models::DBUserId(c.id)) .try_collect::>() - .await?; + .await + .wrap_internal_err("fetching notified members from database")?; NotificationBuilder { body: NotificationBody::StatusChange { @@ -606,7 +628,10 @@ pub async fn project_edit_internal( }, } .insert_many(notified_members.clone(), &mut transaction, &redis) - .await?; + .await + .wrap_internal_err( + "inserting database records for `project_edit_internal`", + )?; NotificationBuilder { body: if status.is_approved() { @@ -622,7 +647,10 @@ pub async fn project_edit_internal( }, } .insert_many(notified_members, &mut transaction, &redis) - .await?; + .await + .wrap_internal_err( + "inserting database records for `project_edit_internal`", + )?; } ThreadMessageBuilder { @@ -635,7 +663,10 @@ pub async fn project_edit_internal( hide_identity: user.role.is_mod(), } .insert(&mut transaction) - .await?; + .await + .wrap_internal_err( + "inserting database records for `project_edit_internal`", + )?; sqlx::query!( " @@ -647,24 +678,24 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_edit_internal`")?; } 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::Auth(eyre::eyre!( + "You do not have the permissions to edit the requested status of this project!", + ))); } if !requested_status .map(|x| x.can_be_requested()) .unwrap_or(true) { - return Err(ApiError::InvalidInput(String::from( + return Err(ApiError::Request(eyre::eyre!(String::from( "Specified status cannot be requested!", - ))); + )))); } sqlx::query!( @@ -677,7 +708,8 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_edit_internal`")?; } if perms.contains(ProjectPermissions::EDIT_DETAILS) { @@ -690,7 +722,10 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "updating database records for `project_edit_internal`", + )?; } if new_project.additional_categories.is_some() { @@ -702,7 +737,10 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `project_edit_internal`", + )?; } } @@ -714,7 +752,8 @@ pub async fn project_edit_internal( false, &mut transaction, ) - .await?; + .await + .wrap_api_err("executing `edit_project_categories`")?; } if let Some(categories) = &new_project.additional_categories { @@ -725,15 +764,15 @@ pub async fn project_edit_internal( true, &mut transaction, ) - .await?; + .await + .wrap_api_err("executing `edit_project_categories`")?; } 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::Auth(eyre::eyre!( + "You do not have the permissions to edit the license URL of this project!", + ))); } sqlx::query!( @@ -746,15 +785,15 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_edit_internal`")?; } 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::Auth(eyre::eyre!( + "You do not have the permissions to edit the slug of this project!", + ))); } let existing = db_models::DBProject::get( @@ -762,11 +801,12 @@ pub async fn project_edit_internal( &mut transaction, &redis, ) - .await?; + .await + .wrap_api_err("checking project slug availability")?; if existing.is_some() { - return Err(ApiError::InvalidInput( - "Slug collides with other project's id!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Slug collides with other project's id!", + ))); } // Make sure the new slug is different from the old one @@ -784,12 +824,15 @@ pub async fn project_edit_internal( slug ) .fetch_one(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `project_edit_internal`", + )?; if results.exists.unwrap_or(true) { - return Err(ApiError::InvalidInput( - "Slug collides with other project's id!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Slug collides with other project's id!", + ))); } } @@ -803,15 +846,15 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_edit_internal`")?; } 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::Auth(eyre::eyre!( + "You do not have the permissions to edit the license of this project!", + ))); } let mut license = license.clone(); @@ -820,11 +863,9 @@ pub async fn project_edit_internal( license = models::projects::DEFAULT_LICENSE_ID.to_string(); } - spdx::Expression::parse(&license).map_err(|err| { - ApiError::InvalidInput(format!( - "Invalid SPDX license identifier: {err}" - )) - })?; + spdx::Expression::parse(&license) + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("parsing request value")?; sqlx::query!( " @@ -836,17 +877,17 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_edit_internal`")?; } if let Some(links) = &new_project.link_urls && !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::Auth(eyre::eyre!( + "You do not have the permissions to edit the links of this project!", + ))); } let ids_to_delete = links.keys().cloned().collect::>(); @@ -862,7 +903,8 @@ pub async fn project_edit_internal( &ids_to_delete ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_edit_internal`")?; for (platform, url) in links { if let Some(url) = url { @@ -870,12 +912,10 @@ pub async fn project_edit_internal( platform, &mut transaction, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Platform {} does not exist.", - platform.clone() - )) + .await + .wrap_internal_err("fetching link platform from database")? + .wrap_request_err_with(|| { + format!("platform `{}` does not exist", platform.clone()) })?; sqlx::query!( " @@ -887,7 +927,7 @@ pub async fn project_edit_internal( url ) .execute(&mut transaction) - .await?; + .await.wrap_internal_err("querying database for `project_edit_internal`")?; } } } @@ -896,10 +936,9 @@ pub async fn project_edit_internal( && (!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::Auth(eyre::eyre!( + "You do not have the permissions to edit the moderation message of this project!", + ))); } sqlx::query!( @@ -912,7 +951,8 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_edit_internal`")?; } if let Some(moderation_message_body) = &new_project.moderation_message_body @@ -921,10 +961,9 @@ pub async fn project_edit_internal( && (!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::Auth(eyre::eyre!( + "You do not have the permissions to edit the moderation message body of this project!", + ))); } sqlx::query!( @@ -937,15 +976,15 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_edit_internal`")?; } 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::Auth(eyre::eyre!( + "You do not have the permissions to edit the description (body) of this project!", + ))); } sqlx::query!( @@ -958,15 +997,15 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_edit_internal`")?; } 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::Auth(eyre::eyre!( + "You do not have the permissions to edit the monetization status of this project!", + ))); } if (*monetization_status == MonetizationStatus::ForceDemonetized @@ -974,10 +1013,9 @@ pub async fn project_edit_internal( == 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::Auth(eyre::eyre!( + "You do not have the permissions to edit the monetization status of this project!", + ))); } sqlx::query!( @@ -990,17 +1028,17 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_edit_internal`")?; } if let Some(side_types_migration_review_status) = &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::Auth(eyre::eyre!( + "You do not have the permissions to edit the side types migration review status of this project!", + ))); } sqlx::query!( @@ -1013,7 +1051,8 @@ pub async fn project_edit_internal( id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_edit_internal`")?; } if !new_project.loader_fields.is_empty() { @@ -1022,7 +1061,8 @@ pub async fn project_edit_internal( &**pool, &redis, ) - .await? + .await + .wrap_internal_err("fetching versions from database")? { match super::versions::version_edit_helper( req.clone(), @@ -1043,7 +1083,7 @@ pub async fn project_edit_internal( // the loaders defined for this version, which is a common case for // projects with heterogeneous loaders across versions and is best // handled with opportunistic update semantics - Ok(_) | Err(ApiError::InvalidInput(_)) => continue, + Ok(_) | Err(ApiError::Request(_)) => continue, err => return err, } } @@ -1064,10 +1104,9 @@ pub async fn project_edit_internal( }; if !perms.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication( - "You do not have the permissions to edit the components of this project!" - .to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You do not have the permissions to edit the components of this project!", + ))); } match (&mut component, edit) { @@ -1112,7 +1151,8 @@ pub async fn project_edit_internal( &mut project_item.inner.components.minecraft_server, perms, ) - .await?; + .await + .wrap_api_err("updating Minecraft server component")?; reindex_versions |= update( &mut transaction, id, @@ -1120,7 +1160,8 @@ pub async fn project_edit_internal( &mut project_item.inner.components.minecraft_java_server, perms, ) - .await?; + .await + .wrap_api_err("updating Minecraft Java server component")?; reindex_versions |= update( &mut transaction, id, @@ -1128,7 +1169,8 @@ pub async fn project_edit_internal( &mut project_item.inner.components.minecraft_bedrock_server, perms, ) - .await?; + .await + .wrap_api_err("updating Minecraft Bedrock server component")?; let components_serial = project_item.inner.components.clone(); @@ -1170,9 +1212,13 @@ pub async fn project_edit_internal( &mut transaction, &redis, ) - .await?; + .await + .wrap_api_err("deleting unused images")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; if reindex_versions { db_models::DBProject::clear_cache( @@ -1181,7 +1227,8 @@ pub async fn project_edit_internal( None, &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_version_changes( @@ -1197,7 +1244,8 @@ pub async fn project_edit_internal( project_item.inner.slug, None, ) - .await?; + .await + .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; } // Remove no longer searchable projects from search index @@ -1223,9 +1271,9 @@ pub async fn edit_project_categories( ) -> Result<(), ApiError> { if !perms.contains(ProjectPermissions::EDIT_DETAILS) { let additional_str = if is_additional { "additional " } else { "" }; - return Err(ApiError::CustomAuthentication(format!( + return Err(ApiError::Auth(eyre::eyre!(format!( "You do not have the permissions to edit the {additional_str}categories of this project!" - ))); + )))); } let mut mod_categories = Vec::new(); @@ -1234,7 +1282,8 @@ pub async fn edit_project_categories( category, &mut *transaction, ) - .await?; + .await + .wrap_internal_err("fetching category from Redis")?; // TODO: We should filter out categories that don't match the project type of any of the versions // ie: if mod and modpack both share a name this should only have modpack if it only has a modpack as a version @@ -1248,7 +1297,9 @@ pub async fn edit_project_categories( .collect::>(); mod_categories.extend(mcategories); } - DBModCategory::insert_many(mod_categories, &mut *transaction).await?; + DBModCategory::insert_many(mod_categories, &mut *transaction) + .await + .wrap_internal_err("inserting mod categories into database")?; Ok(()) } @@ -1290,7 +1341,8 @@ pub async fn project_search( ) -> Result, ApiError> { let results = search_backend .search_for_project(&SearchRequest::from(info), &redis) - .await?; + .await + .wrap_api_err("searching projects")?; // TODO: add this back // let results = ReturnSearchResults { @@ -1320,7 +1372,10 @@ pub async fn project_search_post( search_backend: web::Data, redis: web::Data, ) -> Result, ApiError> { - let results = search_backend.search_for_project(&info, &redis).await?; + let results = search_backend + .search_for_project(&info, &redis) + .await + .wrap_api_err("searching projects")?; Ok(web::Json(results)) } @@ -1346,15 +1401,16 @@ pub async fn project_get_check_internal( ) -> Result { let slug = info.into_inner().0; - let project_data = - db_models::DBProject::get(&slug, &**pool, &redis).await?; + let project_data = db_models::DBProject::get(&slug, &**pool, &redis) + .await + .wrap_api_err("fetching project from database")?; if let Some(project) = project_data { Ok(HttpResponse::Ok().json(ProjectCheckResponse { id: models::ids::ProjectId::from(project.inner.id), })) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -1392,8 +1448,9 @@ pub async fn dependency_list_internal( ) -> Result { let string = info.into_inner().0; - let result = - db_models::DBProject::get(&string, &***ro_pool, &redis).await?; + let result = db_models::DBProject::get(&string, &***ro_pool, &redis) + .await + .wrap_api_err("fetching project from database")?; let user_option = get_user_from_headers( &req, @@ -1408,9 +1465,10 @@ pub async fn dependency_list_internal( if let Some(project) = result { if !is_visible_project(&project.inner, &user_option, &pool, false) - .await? + .await + .wrap_api_err("checking project visibility")? { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } let dependencies = database::DBProject::get_dependencies( @@ -1418,7 +1476,8 @@ pub async fn dependency_list_internal( &***ro_pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching projects from database")?; let project_ids = dependencies .iter() .filter_map(|x| { @@ -1456,7 +1515,8 @@ pub async fn dependency_list_internal( .wrap_internal_err("failed to fetch dependency versions") }, ) - .await?; + .await + .wrap_api_err("fetching project dependencies")?; let mut projects = filter_visible_projects( projects_result, @@ -1464,7 +1524,8 @@ pub async fn dependency_list_internal( &pool, false, ) - .await?; + .await + .wrap_api_err("filtering visible projects")?; let mut versions = filter_visible_versions( versions_result, &user_option, @@ -1472,7 +1533,8 @@ pub async fn dependency_list_internal( &ro_pool, &redis, ) - .await?; + .await + .wrap_api_err("filtering visible versions")?; projects.sort_by_key(|b| Reverse(b.published)); projects.dedup_by(|a, b| a.id == b.id); @@ -1482,7 +1544,7 @@ pub async fn dependency_list_internal( Ok(HttpResponse::Ok().json(DependencyInfo { projects, versions })) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -1555,31 +1617,35 @@ pub async fn projects_edit( &session_queue, Scopes::PROJECT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - bulk_edit_project.validate().map_err(|err| { - ApiError::Validation(validation_errors_to_string(err, None)) - })?; + bulk_edit_project + .validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; let project_ids: Vec = - serde_json::from_str::>(&ids.ids)? + serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing JSON data")? .into_iter() .map(|x| x.into()) .collect(); let projects_data = db_models::DBProject::get_many_ids(&project_ids, &**pool, &redis) - .await?; + .await + .wrap_api_err("fetching projects to edit")?; if let Some(id) = project_ids .iter() .find(|x| !projects_data.iter().any(|y| x == &&y.inner.id)) { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "Project {} not found", ProjectId(id.0 as u64) - ))); + )))); } let team_ids = projects_data @@ -1589,7 +1655,8 @@ pub async fn projects_edit( let team_members = db_models::DBTeamMember::get_from_team_full_many( &team_ids, &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; let organization_ids = projects_data .iter() @@ -1600,7 +1667,8 @@ pub async fn projects_edit( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching organizations from database")?; let organization_team_ids = organizations .iter() @@ -1612,14 +1680,21 @@ pub async fn projects_edit( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; - let categories = - db_models::categories::Category::list(&**pool, &redis).await?; + let categories = db_models::categories::Category::list(&**pool, &redis) + .await + .wrap_internal_err("fetching category from Redis")?; let link_platforms = - db_models::categories::LinkPlatform::list(&**pool, &redis).await?; + db_models::categories::LinkPlatform::list(&**pool, &redis) + .await + .wrap_internal_err("fetching link platform from Redis")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let mut changed_projects = Vec::new(); for project in projects_data { @@ -1653,21 +1728,21 @@ pub async fn projects_edit( if team_member.is_some() { if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { - return Err(ApiError::CustomAuthentication(format!( + return Err(ApiError::Auth(eyre::eyre!(format!( "You do not have the permissions to bulk edit project {}!", project.inner.name - ))); + )))); } } else if project.inner.status.is_hidden() { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "Project {} not found", ProjectId(project.inner.id.0 as u64) - ))); + )))); } else { - return Err(ApiError::CustomAuthentication(format!( + return Err(ApiError::Auth(eyre::eyre!(format!( "You are not a member of project {}!", project.inner.name - ))); + )))); }; } @@ -1684,7 +1759,8 @@ pub async fn projects_edit( false, &mut transaction, ) - .await?; + .await + .wrap_api_err("executing `bulk_edit_project_categories`")?; reindex_versions |= bulk_edit_project_categories( &categories, @@ -1700,7 +1776,8 @@ pub async fn projects_edit( true, &mut transaction, ) - .await?; + .await + .wrap_api_err("executing `bulk_edit_project_categories`")?; if let Some(links) = &bulk_edit_project.link_urls { let ids_to_delete = links.keys().cloned().collect::>(); @@ -1716,18 +1793,19 @@ pub async fn projects_edit( &ids_to_delete ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `projects_edit`")?; for (platform, url) in links { if let Some(url) = url { let platform_id = link_platforms .iter() .find(|x| &x.name == platform) - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Platform {} does not exist.", + .wrap_request_err_with(|| { + format!( + "platform `{}` does not exist", platform.clone() - )) + ) })? .id; sqlx::query!( @@ -1740,7 +1818,7 @@ pub async fn projects_edit( url ) .execute(&mut transaction) - .await?; + .await.wrap_internal_err("querying database for `projects_edit`")?; } } } @@ -1753,12 +1831,16 @@ pub async fn projects_edit( )); } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; for (project_id, slug, versions, reindex_versions) in changed_projects { if reindex_versions { db_models::DBProject::clear_cache(project_id, slug, None, &redis) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_version_changes( @@ -1774,7 +1856,8 @@ pub async fn projects_edit( slug, None, ) - .await?; + .await + .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; } } @@ -1827,18 +1910,18 @@ pub async fn bulk_edit_project_categories( is_additional ) .execute(&mut *transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `bulk_edit_project_categories`", + )?; let mut mod_categories = Vec::new(); for category in set_categories { let category_id = all_db_categories .iter() .find(|x| x.category == category) - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Category {} does not exist.", - category.clone() - )) + .wrap_request_err_with(|| { + format!("category `{}` does not exist", category.clone()) })? .id; mod_categories.push(DBModCategory { @@ -1847,7 +1930,9 @@ pub async fn bulk_edit_project_categories( is_additional, }); } - DBModCategory::insert_many(mod_categories, &mut *transaction).await?; + DBModCategory::insert_many(mod_categories, &mut *transaction) + .await + .wrap_internal_err("inserting mod categories into database")?; } Ok(changed) @@ -1913,16 +1998,16 @@ pub async fn project_icon_edit_internal( &session_queue, Scopes::PROJECT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let string = info.into_inner().0; let project_item = db_models::DBProject::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified project does not exist!".to_string(), - ) + .await + .wrap_api_err("fetching project from database")? + .wrap_request_err_with(|| { + "the specified project does not exist!".to_string() })?; if !user.role.is_mod() { @@ -1932,13 +2017,14 @@ pub async fn project_icon_edit_internal( user.id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; // Hide the project if team_member.is_none() && organization_team_member.is_none() { - return Err(ApiError::CustomAuthentication( - "The specified project does not exist!".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "The specified project does not exist!", + ))); } let permissions = ProjectPermissions::get_permissions_by_role( @@ -1949,10 +2035,9 @@ pub async fn project_icon_edit_internal( .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::Auth(eyre::eyre!( + "You don't have permission to edit this project's icon.", + ))); } } @@ -1962,14 +2047,16 @@ pub async fn project_icon_edit_internal( FileHostPublicity::Public, &**file_host, ) - .await?; + .await + .wrap_api_err("deleting old images")?; let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", ) - .await?; + .await + .wrap_api_err("executing `read_limited_from_payload`")?; let project_id: ProjectId = project_item.inner.id.into(); let upload_result = upload_image_optimized( @@ -1981,9 +2068,13 @@ pub async fn project_icon_edit_internal( Some(1.0), &**file_host, ) - .await?; + .await + .wrap_api_err("uploading image")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " @@ -1997,9 +2088,13 @@ pub async fn project_icon_edit_internal( project_item.inner.id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_icon_edit_internal`")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; clear_project_cache_and_queue_search( &redis, &search_state, @@ -2007,7 +2102,8 @@ pub async fn project_icon_edit_internal( project_item.inner.slug, None, ) - .await?; + .await + .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; Ok(HttpResponse::NoContent().body("")) } @@ -2055,16 +2151,16 @@ pub async fn delete_project_icon_internal( &session_queue, Scopes::PROJECT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let string = info.into_inner().0; let project_item = db_models::DBProject::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified project does not exist!".to_string(), - ) + .await + .wrap_api_err("fetching project from database")? + .wrap_request_err_with(|| { + "the specified project does not exist!".to_string() })?; if !user.role.is_mod() { @@ -2074,13 +2170,14 @@ pub async fn delete_project_icon_internal( user.id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; // Hide the project if team_member.is_none() && organization_team_member.is_none() { - return Err(ApiError::CustomAuthentication( - "The specified project does not exist!".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "The specified project does not exist!", + ))); } let permissions = ProjectPermissions::get_permissions_by_role( &user.role, @@ -2090,10 +2187,9 @@ pub async fn delete_project_icon_internal( .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::Auth(eyre::eyre!( + "You don't have permission to edit this project's icon.", + ))); } } @@ -2103,9 +2199,13 @@ pub async fn delete_project_icon_internal( FileHostPublicity::Public, &**file_host, ) - .await?; + .await + .wrap_api_err("deleting old images")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " @@ -2116,9 +2216,15 @@ pub async fn delete_project_icon_internal( project_item.inner.id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `delete_project_icon_internal`", + )?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; clear_project_cache_and_queue_search( &redis, &search_state, @@ -2126,7 +2232,8 @@ pub async fn delete_project_icon_internal( project_item.inner.slug, None, ) - .await?; + .await + .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; Ok(HttpResponse::NoContent().body("")) } @@ -2196,9 +2303,9 @@ pub async fn add_gallery_item_internal( session_queue: web::Data, search_state: web::Data, ) -> Result { - item.validate().map_err(|err| { - ApiError::Validation(validation_errors_to_string(err, None)) - })?; + item.validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; let user = get_user_from_headers( &req, @@ -2207,23 +2314,22 @@ pub async fn add_gallery_item_internal( &session_queue, Scopes::PROJECT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let string = info.into_inner().0; let project_item = db_models::DBProject::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified project does not exist!".to_string(), - ) + .await + .wrap_api_err("fetching project from database")? + .wrap_request_err_with(|| { + "the specified project does not exist!".to_string() })?; if project_item.gallery_items.len() > 64 { - return Err(ApiError::CustomAuthentication( - "You have reached the maximum of gallery images to upload." - .to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You have reached the maximum of gallery images to upload.", + ))); } if !user.role.is_admin() { @@ -2233,13 +2339,14 @@ pub async fn add_gallery_item_internal( user.id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; // Hide the project if team_member.is_none() && organization_team_member.is_none() { - return Err(ApiError::CustomAuthentication( - "The specified project does not exist!".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "The specified project does not exist!", + ))); } let permissions = ProjectPermissions::get_permissions_by_role( @@ -2250,10 +2357,9 @@ pub async fn add_gallery_item_internal( .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::Auth(eyre::eyre!( + "You don't have permission to edit this project's gallery.", + ))); } } @@ -2262,7 +2368,8 @@ pub async fn add_gallery_item_internal( 5 * (1 << 20), "Gallery image exceeds the maximum of 5MiB.", ) - .await?; + .await + .wrap_api_err("executing `read_limited_from_payload`")?; let id: ProjectId = project_item.inner.id.into(); let upload_result = upload_image_optimized( @@ -2274,19 +2381,23 @@ pub async fn add_gallery_item_internal( Some(1.0), &**file_host, ) - .await?; + .await + .wrap_api_err("uploading image")?; if project_item .gallery_items .iter() .any(|x| x.image_url == upload_result.url) { - return Err(ApiError::InvalidInput( - "You may not upload duplicate gallery images!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You may not upload duplicate gallery images!", + ))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; if item.featured { sqlx::query!( @@ -2299,7 +2410,10 @@ pub async fn add_gallery_item_internal( false, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `add_gallery_item_internal`", + )?; } let gallery_item = vec![db_models::project_item::DBGalleryItem { @@ -2316,9 +2430,13 @@ pub async fn add_gallery_item_internal( project_item.inner.id, &mut transaction, ) - .await?; + .await + .wrap_internal_err("inserting galleries into database")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; clear_project_cache_and_queue_search( &redis, &search_state, @@ -2326,7 +2444,8 @@ pub async fn add_gallery_item_internal( project_item.inner.slug, None, ) - .await?; + .await + .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; Ok(HttpResponse::NoContent().body("")) } @@ -2401,12 +2520,13 @@ pub async fn edit_gallery_item_internal( &session_queue, Scopes::PROJECT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - item.validate().map_err(|err| { - ApiError::Validation(validation_errors_to_string(err, None)) - })?; + item.validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; let result = sqlx::query!( " @@ -2416,12 +2536,13 @@ pub async fn edit_gallery_item_internal( item.url ) .fetch_optional(&**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Gallery item at URL {} is not part of the project's gallery.", + .await + .wrap_internal_err("querying database for `edit_gallery_item_internal`")? + .wrap_request_err_with(|| { + format!( + "gallery item at URL `{}` is not part of the project's gallery", item.url - )) + ) })?; let project_item = db_models::DBProject::get_id( @@ -2429,11 +2550,10 @@ pub async fn edit_gallery_item_internal( &**pool, &redis, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified project does not exist!".to_string(), - ) + .await + .wrap_api_err("fetching project from database")? + .wrap_request_err_with(|| { + "the specified project does not exist!".to_string() })?; if !user.role.is_mod() { @@ -2443,13 +2563,14 @@ pub async fn edit_gallery_item_internal( user.id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; // Hide the project if team_member.is_none() && organization_team_member.is_none() { - return Err(ApiError::CustomAuthentication( - "The specified project does not exist!".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "The specified project does not exist!", + ))); } let permissions = ProjectPermissions::get_permissions_by_role( &user.role, @@ -2459,14 +2580,16 @@ pub async fn edit_gallery_item_internal( .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::Auth(eyre::eyre!( + "You don't have permission to edit this project's gallery.", + ))); } } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; if let Some(featured) = item.featured { if featured { @@ -2480,7 +2603,8 @@ pub async fn edit_gallery_item_internal( false, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("fetching featured status from database")?; } sqlx::query!( @@ -2493,7 +2617,10 @@ pub async fn edit_gallery_item_internal( featured ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `edit_gallery_item_internal`", + )?; } if let Some(name) = item.name { sqlx::query!( @@ -2506,7 +2633,10 @@ pub async fn edit_gallery_item_internal( name ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `edit_gallery_item_internal`", + )?; } if let Some(description) = item.description { sqlx::query!( @@ -2519,7 +2649,10 @@ pub async fn edit_gallery_item_internal( description ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `edit_gallery_item_internal`", + )?; } if let Some(ordering) = item.ordering { sqlx::query!( @@ -2532,10 +2665,16 @@ pub async fn edit_gallery_item_internal( ordering ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `edit_gallery_item_internal`", + )?; } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; clear_project_cache_and_queue_search( &redis, @@ -2544,7 +2683,8 @@ pub async fn edit_gallery_item_internal( project_item.inner.slug, None, ) - .await?; + .await + .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; Ok(HttpResponse::NoContent().body("")) } @@ -2601,7 +2741,8 @@ pub async fn delete_gallery_item_internal( &session_queue, Scopes::PROJECT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let item = sqlx::query!( @@ -2612,12 +2753,13 @@ pub async fn delete_gallery_item_internal( item.url ) .fetch_optional(&**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Gallery item at URL {} is not part of the project's gallery.", + .await + .wrap_internal_err("querying database for `delete_gallery_item_internal`")? + .wrap_request_err_with(|| { + format!( + "gallery item at URL `{}` is not part of the project's gallery", item.url - )) + ) })?; let project_item = db_models::DBProject::get_id( @@ -2625,11 +2767,10 @@ pub async fn delete_gallery_item_internal( &**pool, &redis, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified project does not exist!".to_string(), - ) + .await + .wrap_api_err("fetching project from database")? + .wrap_request_err_with(|| { + "the specified project does not exist!".to_string() })?; if !user.role.is_mod() { @@ -2639,13 +2780,14 @@ pub async fn delete_gallery_item_internal( user.id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; // Hide the project if team_member.is_none() && organization_team_member.is_none() { - return Err(ApiError::CustomAuthentication( - "The specified project does not exist!".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "The specified project does not exist!", + ))); } let permissions = ProjectPermissions::get_permissions_by_role( @@ -2656,10 +2798,9 @@ pub async fn delete_gallery_item_internal( .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::Auth(eyre::eyre!( + "You don't have permission to edit this project's gallery.", + ))); } } @@ -2669,9 +2810,13 @@ pub async fn delete_gallery_item_internal( FileHostPublicity::Public, &**file_host, ) - .await?; + .await + .wrap_api_err("deleting old images")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " @@ -2681,9 +2826,15 @@ pub async fn delete_gallery_item_internal( item.id ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `delete_gallery_item_internal`", + )?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; clear_project_cache_and_queue_search( &redis, @@ -2692,7 +2843,8 @@ pub async fn delete_gallery_item_internal( project_item.inner.slug, None, ) - .await?; + .await + .wrap_api_err("executing `clear_project_cache_and_queue_search`")?; Ok(HttpResponse::NoContent().body("")) } @@ -2730,7 +2882,8 @@ pub async fn project_delete_internal( &session_queue, Scopes::PROJECT_DELETE, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let string = info.into_inner().0; // In two cases, we return `The specified project does not exist!`: @@ -2744,7 +2897,7 @@ pub async fn project_delete_internal( let project = db_models::DBProject::get(&string, &**pool, &redis) .await .wrap_internal_err("failed to get project")? - .wrap_auth_err("The specified project does not exist!")?; + .wrap_auth_err("the specified project does not exist")?; if !user.role.is_admin() { let (team_member, organization_team_member) = @@ -2771,9 +2924,9 @@ pub async fn project_delete_internal( .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::Auth(eyre::eyre!( + "You don't have permission to delete this project!", + ))); } } @@ -2785,7 +2938,10 @@ pub async fn project_delete_internal( project.inner.id, &mut transaction, ) - .await?; + .await + .wrap_api_err( + "executing `tech_review_sync::sync_deleted_project_tech_review_exit`", + )?; let context = ImageContext::Project { project_id: Some(project.inner.id.into()), @@ -2833,14 +2989,15 @@ pub async fn project_delete_internal( None, &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_project_removal(project.inner.id.into()) .await; Ok(()) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -2874,23 +3031,26 @@ pub async fn project_follow_internal( &session_queue, Scopes::USER_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let string = info.into_inner().0; let result = db_models::DBProject::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified project does not exist!".to_string(), - ) + .await + .wrap_api_err("fetching project from database")? + .wrap_request_err_with(|| { + "the specified project does not exist!".to_string() })?; let user_id: db_ids::DBUserId = user.id.into(); let project_id: db_ids::DBProjectId = result.inner.id; - if !is_visible_project(&result.inner, &Some(user), &pool, false).await? { - return Err(ApiError::NotFound); + if !is_visible_project(&result.inner, &Some(user), &pool, false) + .await + .wrap_api_err("checking project visibility")? + { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } let following = sqlx::query!( @@ -2901,12 +3061,15 @@ pub async fn project_follow_internal( project_id as db_ids::DBProjectId ) .fetch_one(&**pool) - .await? + .await.wrap_internal_err("fetching project follow status from database")? .exists .unwrap_or(false); if !following { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " @@ -2917,7 +3080,8 @@ pub async fn project_follow_internal( project_id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_follow_internal`")?; sqlx::query!( " @@ -2928,15 +3092,19 @@ pub async fn project_follow_internal( project_id as db_ids::DBProjectId ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `project_follow_internal`")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::InvalidInput( - "You are already following this project!".to_string(), - )) + Err(ApiError::Request(eyre::eyre!( + "You are already following this project!", + ))) } } @@ -2970,16 +3138,16 @@ pub async fn project_unfollow_internal( &session_queue, Scopes::USER_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let string = info.into_inner().0; let result = db_models::DBProject::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified project does not exist!".to_string(), - ) + .await + .wrap_api_err("fetching project from database")? + .wrap_request_err_with(|| { + "the specified project does not exist!".to_string() })?; let user_id: db_ids::DBUserId = user.id.into(); @@ -2993,12 +3161,15 @@ pub async fn project_unfollow_internal( project_id as db_ids::DBProjectId ) .fetch_one(&**pool) - .await? + .await.wrap_internal_err("fetching project follow status from database")? .exists .unwrap_or(false); if following { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " @@ -3009,7 +3180,10 @@ pub async fn project_unfollow_internal( project_id as db_ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `project_unfollow_internal`", + )?; sqlx::query!( " @@ -3020,15 +3194,21 @@ pub async fn project_unfollow_internal( project_id as db_ids::DBProjectId ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `project_unfollow_internal`", + )?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::InvalidInput( - "You are not following this project!".to_string(), - )) + Err(ApiError::Request(eyre::eyre!( + "You are not following this project!", + ))) } } @@ -3059,25 +3239,26 @@ pub async fn project_get_organization( let string = info.into_inner().0; let result = db_models::DBProject::get(&string, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified project does not exist!".to_string(), - ) + .await + .wrap_api_err("fetching project from database")? + .wrap_request_err_with(|| { + "the specified project does not exist!".to_string() })?; - if !is_visible_project(&result.inner, ¤t_user, &pool, false).await? { - Err(ApiError::InvalidInput( - "The specified project does not exist!".to_string(), - )) + if !is_visible_project(&result.inner, ¤t_user, &pool, false) + .await + .wrap_api_err("checking project visibility")? + { + Err(ApiError::Request(eyre::eyre!( + "The specified project does not exist!", + ))) } else if let Some(organization_id) = result.inner.organization_id { let organization = db_models::DBOrganization::get_id(organization_id, &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The attached organization does not exist!".to_string(), - ) + .await + .wrap_internal_err("fetching organization from database")? + .wrap_request_err_with(|| { + "the attached organization does not exist!".to_string() })?; let members_data = DBTeamMember::get_from_team_full( @@ -3085,14 +3266,16 @@ pub async fn project_get_organization( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; let users = crate::database::models::DBUser::get_many_ids( &members_data.iter().map(|x| x.user_id).collect::>(), &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching users from database")?; let logged_in = current_user .as_ref() .and_then(|user| { @@ -3127,6 +3310,6 @@ pub async fn project_get_organization( ); Ok(HttpResponse::Ok().json(organization)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } diff --git a/apps/labrinth/src/routes/v3/reports.rs b/apps/labrinth/src/routes/v3/reports.rs index 04dc2b3028..a3bba51279 100644 --- a/apps/labrinth/src/routes/v3/reports.rs +++ b/apps/labrinth/src/routes/v3/reports.rs @@ -17,6 +17,7 @@ use crate::models::reports::{ItemType, Report}; use crate::models::threads::{MessageBody, ThreadType}; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::util::error::ApiContext as _; use crate::util::error::Context; use crate::util::http::HTTP_CLIENT; use crate::util::img; @@ -73,7 +74,10 @@ pub async fn report_create( redis: web::Data, session_queue: web::Data, ) -> Result { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let current_user = get_user_from_headers( &req, @@ -82,23 +86,25 @@ pub async fn report_create( &session_queue, Scopes::REPORT_CREATE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - let new_report: CreateReport = read_typed_from_payload(&mut body).await?; + let new_report: CreateReport = read_typed_from_payload(&mut body) + .await + .wrap_api_err("reading request payload")?; - let id = - crate::database::models::generate_report_id(&mut transaction).await?; + let id = crate::database::models::generate_report_id(&mut transaction) + .await + .wrap_internal_err("generating report ID")?; let report_type = crate::database::models::categories::ReportType::get_id( &new_report.report_type, &mut transaction, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Invalid report type: {}", - new_report.report_type - )) + .await + .wrap_internal_err("generating report ID")? + .wrap_request_err_with(|| { + format!("invalid report type: `{}`", new_report.report_type) })?; let mut report = crate::database::models::report_item::DBReport { @@ -117,60 +123,70 @@ pub async fn report_create( match new_report.item_type { ItemType::Project => { - let project_id = - ProjectId(parse_base62(new_report.item_id.as_str())?); + let project_id = ProjectId( + parse_base62(new_report.item_id.as_str()) + .wrap_request_err("parsing reported project ID")?, + ); let result = sqlx::query!( "SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1)", project_id.0 as i64 ) .fetch_one(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `report_create`")?; if !result.exists.unwrap_or(false) { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "Project could not be found: {}", new_report.item_id - ))); + )))); } report.project_id = Some(project_id.into()) } ItemType::Version => { - let version_id = - VersionId(parse_base62(new_report.item_id.as_str())?); + let version_id = VersionId( + parse_base62(new_report.item_id.as_str()) + .wrap_request_err("parsing reported version ID")?, + ); let result = sqlx::query!( "SELECT EXISTS(SELECT 1 FROM versions WHERE id = $1)", version_id.0 as i64 ) .fetch_one(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `report_create`")?; if !result.exists.unwrap_or(false) { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "Version could not be found: {}", new_report.item_id - ))); + )))); } report.version_id = Some(version_id.into()) } ItemType::User => { - let user_id = UserId(parse_base62(new_report.item_id.as_str())?); + let user_id = UserId( + parse_base62(new_report.item_id.as_str()) + .wrap_request_err("parsing reported user ID")?, + ); let result = sqlx::query!( "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)", user_id.0 as i64 ) .fetch_one(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `report_create`")?; if !result.exists.unwrap_or(false) { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "User could not be found: {}", new_report.item_id - ))); + )))); } report.user_id = Some(user_id.into()) @@ -180,21 +196,18 @@ pub async fn report_create( let (instance_part, version_part) = new_report .item_id .split_once('/') - .ok_or_else(|| { - ApiError::InvalidInput( - "Shared instance reports must format the item ID as `instance_id/version_id`" - .to_string(), - ) - })?; - - let shared_instance_id = - SharedInstanceId(parse_base62(instance_part)? as i64); + .wrap_request_err_with(|| "shared instance reports must format the item ID as `instance_id/version_id`" + .to_string())?; + + let shared_instance_id = SharedInstanceId( + parse_base62(instance_part) + .wrap_request_err("parsing reported shared instance ID")? + as i64, + ); let shared_instance_version_id: i32 = - version_part.parse().map_err(|_| { - ApiError::InvalidInput(format!( - "Shared instance version is not a number: {version_part}" - )) - })?; + version_part.parse().wrap_request_err(format!( + "shared instance version is not a number: `{version_part}`" + ))?; // validation let url = format!( @@ -212,9 +225,9 @@ pub async fn report_create( )?; if !instance_response.status().is_success() { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "Shared instance could not be found: {instance_part}" - ))); + )))); } let version_response = HTTP_CLIENT @@ -225,36 +238,40 @@ pub async fn report_create( .wrap_internal_err("failed to reach the shared instance service (version lookup)")?; if !version_response.status().is_success() { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "Shared instance version could not be found: {instance_part}/{version_part}" - ))); + )))); } report.shared_instance_id = Some(shared_instance_id); report.shared_instance_version_id = Some(shared_instance_version_id) } ItemType::Unknown => { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "Invalid report item type: {}", new_report.item_type.as_str() - ))); + )))); } } - report.insert(&mut transaction).await?; + report + .insert(&mut transaction) + .await + .wrap_internal_err("inserting database records for `report_create`")?; for image_id in new_report.uploaded_images { if let Some(db_image) = image_item::DBImage::get(image_id.into(), &mut transaction, &redis) - .await? + .await + .wrap_internal_err("fetching image from database")? { let image: Image = db_image.into(); if !matches!(image.context, ImageContext::Report { .. }) || image.context.inner_id().is_some() { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "Image {image_id} is not unused and in the 'report' context" - ))); + )))); } sqlx::query!( @@ -267,13 +284,16 @@ pub async fn report_create( image_id.0 as i64 ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `report_create`")?; - image_item::DBImage::clear_cache(image.id.into(), &redis).await?; + image_item::DBImage::clear_cache(image.id.into(), &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; } else { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "Image {image_id} could not be found" - ))); + )))); } } @@ -284,7 +304,8 @@ pub async fn report_create( report_id: Some(report.id), } .insert(&mut transaction) - .await?; + .await + .wrap_internal_err("inserting database records for `report_create`")?; // Notify the reporter that the report has been submitted NotificationBuilder { @@ -293,9 +314,13 @@ pub async fn report_create( }, } .insert(current_user.id.into(), &mut transaction, &redis) - .await?; + .await + .wrap_internal_err("inserting database records for `report_create`")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::Ok().json(Report { id: id.into(), @@ -362,7 +387,8 @@ pub async fn reports( &session_queue, Scopes::REPORT_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; use futures::stream::TryStreamExt; @@ -382,7 +408,8 @@ pub async fn reports( .fetch(&**pool) .map_ok(|m| crate::database::models::ids::DBReportId(m.id)) .try_collect::>() - .await? + .await + .wrap_internal_err("fetching report IDs from database")? } else { sqlx::query!( " @@ -399,7 +426,8 @@ pub async fn reports( .fetch(&**pool) .map_ok(|m| crate::database::models::ids::DBReportId(m.id)) .try_collect::>() - .await? + .await + .wrap_internal_err("querying database for `reports`")? }; let query_reports = @@ -407,7 +435,8 @@ pub async fn reports( &report_ids, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching reports from database")?; let mut reports: Vec = Vec::new(); @@ -447,7 +476,8 @@ pub async fn reports_get( session_queue: web::Data, ) -> Result { let report_ids: Vec = - serde_json::from_str::>(&ids.ids)? + serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing JSON data")? .into_iter() .map(|x| x.into()) .collect(); @@ -457,7 +487,8 @@ pub async fn reports_get( &report_ids, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching reports from database")?; let user = get_user_from_headers( &req, @@ -466,7 +497,8 @@ pub async fn reports_get( &session_queue, Scopes::REPORT_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let all_reports = reports_data @@ -504,23 +536,25 @@ pub async fn report_get( &session_queue, Scopes::REPORT_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let id = info.into_inner().0.into(); let report = crate::database::models::report_item::DBReport::get(id, &**pool) - .await?; + .await + .wrap_internal_err("fetching report from database")?; if let Some(report) = report { if !user.role.is_mod() && report.reporter != user.id.into() { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } let report: Report = report.into(); Ok(HttpResponse::Ok().json(report)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -559,20 +593,25 @@ pub async fn report_edit( &session_queue, Scopes::REPORT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let id = info.into_inner().0.into(); let report = crate::database::models::report_item::DBReport::get(id, &**pool) - .await?; + .await + .wrap_internal_err("fetching report from database")?; if let Some(report) = report { if !user.role.is_mod() && report.reporter != user.id.into() { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; if let Some(edit_body) = &edit_report.body { sqlx::query!( @@ -585,14 +624,15 @@ pub async fn report_edit( id as crate::database::models::ids::DBReportId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("updating edit body in database")?; } if let Some(edit_closed) = edit_report.closed { if !user.role.is_mod() { - return Err(ApiError::InvalidInput( - "You cannot reopen a report!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You cannot reopen a report!", + ))); } ThreadMessageBuilder { @@ -606,7 +646,10 @@ pub async fn report_edit( hide_identity: user.role.is_mod(), } .insert(&mut transaction) - .await?; + .await + .wrap_internal_err( + "inserting database records for `report_edit`", + )?; NotificationBuilder { body: NotificationBody::ReportStatusUpdated { @@ -614,7 +657,10 @@ pub async fn report_edit( }, } .insert(report.reporter, &mut transaction, &redis) - .await?; + .await + .wrap_internal_err( + "inserting database records for `report_edit`", + )?; sqlx::query!( " @@ -626,7 +672,8 @@ pub async fn report_edit( id as crate::database::models::ids::DBReportId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("updating database records for `report_edit`")?; } // delete any images no longer in the body @@ -643,13 +690,17 @@ pub async fn report_edit( &mut transaction, &redis, ) - .await?; + .await + .wrap_api_err("deleting unused images")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -679,9 +730,13 @@ pub async fn report_delete( &session_queue, Scopes::REPORT_DELETE, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let id = info.into_inner().0; let context = ImageContext::Report { @@ -691,21 +746,28 @@ pub async fn report_delete( context, &mut transaction, ) - .await?; + .await + .wrap_internal_err("fetching images from database")?; for image in uploaded_images { - image_item::DBImage::remove(image.id, &mut transaction, &redis).await?; + image_item::DBImage::remove(image.id, &mut transaction, &redis) + .await + .wrap_internal_err("deleting image from database")?; } let result = crate::database::models::report_item::DBReport::remove_full( id.into(), &mut transaction, ) - .await?; - transaction.commit().await?; + .await + .wrap_internal_err("deleting report from database")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; if result.is_some() { Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } diff --git a/apps/labrinth/src/routes/v3/statistics.rs b/apps/labrinth/src/routes/v3/statistics.rs index c09797e5de..b607eac5ab 100644 --- a/apps/labrinth/src/routes/v3/statistics.rs +++ b/apps/labrinth/src/routes/v3/statistics.rs @@ -1,5 +1,6 @@ use crate::database::PgPool; use crate::routes::ApiError; +use crate::util::error::Context as _; use actix_web::{HttpResponse, get, web}; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { @@ -37,7 +38,8 @@ pub async fn get_stats( .collect::>(), ) .fetch_one(&**pool) - .await?; + .await + .wrap_internal_err("counting projects")?; let versions = sqlx::query!( " @@ -56,7 +58,8 @@ pub async fn get_stats( .collect::>(), ) .fetch_one(&**pool) - .await?; + .await + .wrap_internal_err("counting versions")?; let authors = sqlx::query!( " @@ -71,7 +74,8 @@ pub async fn get_stats( .collect::>(), ) .fetch_one(&**pool) - .await?; + .await + .wrap_internal_err("counting project authors")?; let files = sqlx::query!( " @@ -89,7 +93,8 @@ pub async fn get_stats( .collect::>(), ) .fetch_one(&**pool) - .await?; + .await + .wrap_internal_err("counting version files")?; let v3_stats = V3Stats { projects: projects.count, diff --git a/apps/labrinth/src/routes/v3/tags.rs b/apps/labrinth/src/routes/v3/tags.rs index 6a499df5df..c2cf2def3c 100644 --- a/apps/labrinth/src/routes/v3/tags.rs +++ b/apps/labrinth/src/routes/v3/tags.rs @@ -1,3 +1,4 @@ +use crate::util::error::Context as _; use std::collections::HashMap; use super::ApiError; @@ -48,7 +49,8 @@ pub async fn games_list( redis: web::Data, ) -> Result { let results = Game::list(&**pool, &redis) - .await? + .await + .wrap_internal_err("fetching game from Redis")? .into_iter() .map(|x| GameData { slug: x.slug, @@ -83,7 +85,8 @@ pub async fn category_list( redis: web::Data, ) -> Result { let results = Category::list(&**pool, &redis) - .await? + .await + .wrap_internal_err("fetching category from Redis")? .into_iter() .map(|x| CategoryData { icon: x.icon, @@ -119,14 +122,17 @@ pub async fn loader_list( pool: web::Data, redis: web::Data, ) -> Result { - let loaders = Loader::list(&**pool, &redis).await?; + let loaders = Loader::list(&**pool, &redis) + .await + .wrap_internal_err("fetching loader from Redis")?; let loader_fields = LoaderField::get_fields_per_loader( &loaders.iter().map(|x| x.id).collect_vec(), &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching loader field from Redis")?; let mut results = loaders .into_iter() @@ -179,25 +185,23 @@ pub async fn loader_fields_list( ) -> Result { let query = query.into_inner(); let loader_field = LoaderField::get_fields_all(&**pool, &redis) - .await? + .await + .wrap_internal_err("fetching loader field from Redis")? .into_iter() .find(|x| x.field == query.loader_field) - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "'{}' was not a valid loader field.", - query.loader_field - )) + .wrap_request_err_with(|| { + format!("'{}' was not a valid loader field.", query.loader_field) })?; let (LoaderFieldType::Enum(loader_field_enum_id) | LoaderFieldType::ArrayEnum(loader_field_enum_id)) = loader_field.field_type else { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "'{}' is not an enumerable field, but an '{}' field.", query.loader_field, loader_field.field_type.to_str() - ))); + )))); }; let results: Vec<_> = if let Some(filters) = query.filters { @@ -207,10 +211,12 @@ pub async fn loader_fields_list( &**pool, &redis, ) - .await? + .await + .wrap_internal_err("fetching loader field enum value from Redis")? } else { LoaderFieldEnumValue::list(loader_field_enum_id, &**pool, &redis) - .await? + .await + .wrap_internal_err("fetching loader field enum value from Redis")? }; Ok(HttpResponse::Ok().json(results)) @@ -275,9 +281,9 @@ pub async fn license_text( })); } - Err(ApiError::InvalidInput( - "Invalid SPDX identifier specified".to_string(), - )) + Err(ApiError::Request(eyre::eyre!( + "Invalid SPDX identifier specified", + ))) } #[derive(serde::Serialize, serde::Deserialize)] @@ -301,7 +307,8 @@ pub async fn link_platform_list( ) -> Result { let results: Vec = LinkPlatform::list(&**pool, &redis) - .await? + .await + .wrap_internal_err("reading HTTP response body")? .into_iter() .map(|x| LinkPlatformQueryData { name: x.name, @@ -324,7 +331,9 @@ pub async fn report_type_list( pool: web::Data, redis: web::Data, ) -> Result { - let results = ReportType::list(&**pool, &redis).await?; + let results = ReportType::list(&**pool, &redis) + .await + .wrap_internal_err("reading HTTP response body")?; Ok(HttpResponse::Ok().json(results)) } @@ -341,6 +350,8 @@ pub async fn project_type_list( pool: web::Data, redis: web::Data, ) -> Result { - let results = ProjectType::list(&**pool, &redis).await?; + let results = ProjectType::list(&**pool, &redis) + .await + .wrap_internal_err("fetching project type from Redis")?; Ok(HttpResponse::Ok().json(results)) } diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs index 3cc0b127e8..5cb8e173bb 100644 --- a/apps/labrinth/src/routes/v3/teams.rs +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -11,6 +11,7 @@ use crate::models::pats::Scopes; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::util::error::ApiContext as _; use crate::util::error::Context; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use ariadne::ids::UserId; @@ -61,7 +62,8 @@ pub async fn team_members_get_project_internal( let string = info.into_inner().0; let project_data = crate::database::models::DBProject::get(&string, &**pool, &redis) - .await?; + .await + .wrap_api_err("fetching project from database")?; if let Some(project) = project_data { let current_user = get_user_from_headers( @@ -76,22 +78,25 @@ pub async fn team_members_get_project_internal( .ok(); if !is_visible_project(&project.inner, ¤t_user, &pool, false) - .await? + .await + .wrap_api_err("checking project visibility")? { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } let members_data = DBTeamMember::get_from_team_full( project.inner.team_id, &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; let users = DBUser::get_many_ids( &members_data.iter().map(|x| x.user_id).collect::>(), &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching users from database")?; let user_id = current_user.as_ref().map(|x| x.id.into()); let logged_in = if let Some(user_id) = user_id { @@ -101,7 +106,8 @@ pub async fn team_members_get_project_internal( user_id, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; team_member.is_some() || organization_team_member.is_some() } else { @@ -130,7 +136,7 @@ pub async fn team_members_get_project_internal( Ok(HttpResponse::Ok().json(team_members)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -146,7 +152,8 @@ pub async fn team_members_get_organization( let string = info.into_inner().0; let organization_data = crate::database::models::DBOrganization::get(&string, &**pool, &redis) - .await?; + .await + .wrap_internal_err("fetching organization from database")?; let current_user = get_user_from_headers( &req, @@ -161,20 +168,23 @@ pub async fn team_members_get_organization( if let Some(organization) = organization_data && is_visible_organization(&organization, ¤t_user, &pool, &redis) - .await? + .await + .wrap_api_err("checking organization visibility")? { let members_data = DBTeamMember::get_from_team_full( organization.team_id, &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; let users = crate::database::models::DBUser::get_many_ids( &members_data.iter().map(|x| x.user_id).collect::>(), &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching users from database")?; let user_id = current_user.as_ref().map(|x| x.id.into()); @@ -208,7 +218,7 @@ pub async fn team_members_get_organization( Ok(HttpResponse::Ok().json(team_members)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -234,13 +244,16 @@ pub async fn team_members_get( ) -> Result { let id = info.into_inner().0; let members_data = - DBTeamMember::get_from_team_full(id.into(), &**pool, &redis).await?; + DBTeamMember::get_from_team_full(id.into(), &**pool, &redis) + .await + .wrap_internal_err("fetching team members from database")?; let users = crate::database::models::DBUser::get_many_ids( &members_data.iter().map(|x| x.user_id).collect::>(), &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching users from database")?; let current_user = get_user_from_headers( &req, @@ -315,20 +328,23 @@ pub async fn teams_get( ) -> Result { use itertools::Itertools; - let team_ids = serde_json::from_str::>(&ids.ids)? + let team_ids = serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing JSON data")? .into_iter() .map(|x| x.into()) .collect::>(); let teams_data = DBTeamMember::get_from_team_full_many(&team_ids, &**pool, &redis) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; let users = crate::database::models::DBUser::get_many_ids( &teams_data.iter().map(|x| x.user_id).collect::>(), &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching users from database")?; let current_user = get_user_from_headers( &req, @@ -403,7 +419,8 @@ pub async fn join_team( &session_queue, Scopes::PROJECT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let member = DBTeamMember::get_from_user_id_pending( @@ -411,15 +428,19 @@ pub async fn join_team( current_user.id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; if let Some(member) = member { if member.accepted { - return Err(ApiError::InvalidInput( - "You are already a member of this team".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You are already a member of this team", + ))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; // Edit Team Member to set Accepted to True DBTeamMember::edit_team_member( @@ -434,16 +455,24 @@ pub async fn join_team( None, &mut transaction, ) - .await?; + .await + .wrap_internal_err("updating team member in database")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; - DBUser::clear_project_cache(&[current_user.id.into()], &redis).await?; - DBTeamMember::clear_cache(team_id, &redis).await?; + DBUser::clear_project_cache(&[current_user.id.into()], &redis) + .await + .wrap_internal_err("clearing cached user in Redis")?; + DBTeamMember::clear_cache(team_id, &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; } else { - return Err(ApiError::InvalidInput( - "There is no pending request from this team".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "There is no pending request from this team", + ))); } Ok(HttpResponse::NoContent().body("")) @@ -496,7 +525,10 @@ pub async fn add_team_member( ) -> Result { let team_id = info.into_inner().0.into(); - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let current_user = get_user_from_headers( &req, @@ -505,21 +537,22 @@ pub async fn add_team_member( &session_queue, Scopes::PROJECT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let team_association = DBTeam::get_association(team_id, &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The team specified does not exist".to_string(), - ) + .await + .wrap_internal_err("fetching team from database")? + .wrap_request_err_with(|| { + "the team specified does not exist".to_string() })?; let member = DBTeamMember::get_from_user_id( team_id, current_user.id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; match team_association { // If team is associated with a project, check if they have permissions to invite users to that project TeamAssociationId::Project(pid) => { @@ -527,7 +560,8 @@ pub async fn add_team_member( DBOrganization::get_associated_organization_project_id( pid, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching organization from database")?; let organization_team_member = if let Some(organization) = &organization { DBTeamMember::get_from_user_id( @@ -535,7 +569,8 @@ pub async fn add_team_member( current_user.id.into(), &**pool, ) - .await? + .await + .wrap_internal_err("fetching team member from database")? } else { None }; @@ -547,23 +582,20 @@ 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::Auth(eyre::eyre!( + "You don't have permission to invite users to this team", + ))); } if !permissions.contains(new_member.permissions) { - return Err(ApiError::InvalidInput( - "The new member has permissions that you don't have" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "The new member has permissions that you don't have", + ))); } if new_member.organization_permissions.is_some() { - return Err(ApiError::InvalidInput( - "The organization permissions of a project team member cannot be set" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "The organization permissions of a project team member cannot be set", + ))); } } // If team is associated with an organization, check if they have permissions to invite users to that organization @@ -577,25 +609,24 @@ 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::Auth(eyre::eyre!( + "You don't have permission to invite users to this organization", + ))); } if !organization_permissions.contains( new_member.organization_permissions.unwrap_or_default(), ) { - return Err(ApiError::InvalidInput( - "The new member has organization permissions that you don't have".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "The new member has organization permissions that you don't have", + ))); } if !organization_permissions.contains( 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::Auth(eyre::eyre!( + "You do not have permission to give this user default project permissions. Ensure 'permissions' is set if it is not, and empty (0).", + ))); } } } @@ -603,9 +634,9 @@ pub async fn add_team_member( if new_member.payouts_split < Decimal::ZERO || new_member.payouts_split > Decimal::from(5000) { - return Err(ApiError::InvalidInput( - "Payouts split must be between 0 and 5000!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Payouts split must be between 0 and 5000!", + ))); } let request = DBTeamMember::get_from_user_id_pending( @@ -613,18 +644,18 @@ pub async fn add_team_member( new_member.user_id.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; if let Some(req) = request { if req.accepted { - return Err(ApiError::InvalidInput( - "The user is already a member of that team".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "The user is already a member of that team", + ))); } else { - return Err(ApiError::InvalidInput( - "There is already a pending member request for this user" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "There is already a pending member request for this user", + ))); } } let new_user = crate::database::models::DBUser::get_id( @@ -632,10 +663,9 @@ pub async fn add_team_member( &**pool, &redis, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("An invalid User ID specified".to_string()) - })?; + .await + .wrap_internal_err("fetching user from database")? + .wrap_request_err_with(|| "an invalid User ID specified".to_string())?; let mut force_accepted = false; if let TeamAssociationId::Project(pid) = team_association { @@ -644,7 +674,8 @@ pub async fn add_team_member( DBOrganization::get_associated_organization_project_id( pid, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching organization from database")?; let new_user_organization_team_member = if let Some(organization) = &organization { DBTeamMember::get_from_user_id( @@ -652,7 +683,8 @@ pub async fn add_team_member( new_user.id, &**pool, ) - .await? + .await + .wrap_internal_err("fetching team member from database")? } else { None }; @@ -661,10 +693,9 @@ pub async fn add_team_member( .is_some_and(|tm| tm.is_owner) && new_member.permissions != ProjectPermissions::all() { - return Err(ApiError::InvalidInput( - "You cannot override the owner of an organization's permissions in a project team" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You cannot override the owner of an organization's permissions in a project team", + ))); } // In the case of adding a user that is in an org, to a project that is owned by that same org, @@ -678,7 +709,8 @@ pub async fn add_team_member( let new_id = crate::database::models::ids::generate_team_member_id(&mut transaction) - .await?; + .await + .wrap_internal_err("generating team member ID")?; DBTeamMember { id: new_id, team_id, @@ -692,7 +724,8 @@ pub async fn add_team_member( ordering: new_member.ordering, } .insert(&mut transaction) - .await?; + .await + .wrap_internal_err("inserting database records for `add_team_member`")?; // If the user has an opportunity to accept the invite, send a notification if !force_accepted { @@ -707,7 +740,10 @@ pub async fn add_team_member( }, } .insert(new_member.user_id.into(), &mut transaction, &redis) - .await?; + .await + .wrap_internal_err( + "inserting database records for `add_team_member`", + )?; } TeamAssociationId::Organization(oid) => { NotificationBuilder { @@ -719,14 +755,24 @@ pub async fn add_team_member( }, } .insert(new_member.user_id.into(), &mut transaction, &redis) - .await?; + .await + .wrap_internal_err( + "inserting database records for `add_team_member`", + )?; } } } - transaction.commit().await?; - DBTeamMember::clear_cache(team_id, &redis).await?; - DBUser::clear_project_cache(&[new_member.user_id.into()], &redis).await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; + DBTeamMember::clear_cache(team_id, &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; + DBUser::clear_project_cache(&[new_member.user_id.into()], &redis) + .await + .wrap_internal_err("clearing cached user in Redis")?; Ok(HttpResponse::NoContent().body("")) } @@ -777,7 +823,8 @@ pub async fn edit_team_member( &session_queue, Scopes::PROJECT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let team_association = DBTeam::get_association(id, &**pool) @@ -786,7 +833,8 @@ pub async fn edit_team_member( .wrap_request_err("the specified team does not exist")?; let member = DBTeamMember::get_from_user_id(id, current_user.id.into(), &**pool) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; let edit_member_db = DBTeamMember::get_from_user_id_pending(id, user_id, &**pool) .await @@ -796,15 +844,18 @@ pub async fn edit_team_member( the member must first be created via `POST`", )?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; if edit_member_db.is_owner && (edit_member.permissions.is_some() || edit_member.organization_permissions.is_some()) { - return Err(ApiError::InvalidInput( - "The owner's permission's in a team cannot be edited".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "The owner's permission's in a team cannot be edited", + ))); } match team_association { @@ -813,7 +864,8 @@ pub async fn edit_team_member( DBOrganization::get_associated_organization_project_id( project_id, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching organization from database")?; let organization_team_member = if let Some(organization) = &organization { DBTeamMember::get_from_user_id( @@ -821,7 +873,8 @@ pub async fn edit_team_member( current_user.id.into(), &**pool, ) - .await? + .await + .wrap_internal_err("fetching team member from database")? } else { None }; @@ -833,7 +886,8 @@ pub async fn edit_team_member( user_id, &**pool, ) - .await? + .await + .wrap_internal_err("fetching team member from database")? } else { None }; @@ -845,10 +899,9 @@ 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::Auth(eyre::eyre!( + "You cannot override the project permissions of the organization owner!", + ))); } let permissions = ProjectPermissions::get_permissions_by_role( @@ -858,26 +911,23 @@ 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::Auth(eyre::eyre!( + "You don't have permission to edit members of this team", + ))); } if let Some(new_permissions) = edit_member.permissions && !permissions.contains(new_permissions) { - return Err(ApiError::InvalidInput( - "The new permissions have permissions that you don't have" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "The new permissions have permissions that you don't have", + ))); } if edit_member.organization_permissions.is_some() { - return Err(ApiError::InvalidInput( - "The organization permissions of a project team member cannot be edited" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "The organization permissions of a project team member cannot be edited", + ))); } } TeamAssociationId::Organization(_) => { @@ -891,19 +941,17 @@ 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::Auth(eyre::eyre!( + "You don't have permission to edit members of this team", + ))); } if let Some(new_permissions) = edit_member.organization_permissions && !organization_permissions.contains(new_permissions) { - return Err(ApiError::InvalidInput( - "The new organization permissions have permissions that you don't have" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "The new organization permissions have permissions that you don't have", + ))); } if edit_member.permissions.is_some() @@ -911,10 +959,9 @@ 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::Auth(eyre::eyre!( + "You do not have permission to give this user default project permissions.", + ))); } } } @@ -923,9 +970,9 @@ pub async fn edit_team_member( && (payouts_split < Decimal::ZERO || payouts_split > Decimal::from(5000)) { - return Err(ApiError::InvalidInput( - "Payouts split must be between 0 and 5000!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Payouts split must be between 0 and 5000!", + ))); } DBTeamMember::edit_team_member( @@ -940,10 +987,16 @@ pub async fn edit_team_member( None, &mut transaction, ) - .await?; + .await + .wrap_internal_err("updating team member in database")?; - transaction.commit().await?; - DBTeamMember::clear_cache(id, &redis).await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; + DBTeamMember::clear_cache(id, &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::NoContent().body("")) } @@ -983,23 +1036,26 @@ pub async fn transfer_ownership( &session_queue, Scopes::PROJECT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; // Forbid transferring ownership of a project team that is owned by an organization // These are owned by the organization owner, and must be removed from the organization first // There shouldnt be an owner on these projects in these cases, but just in case. - let team_association_id = - DBTeam::get_association(id.into(), &**pool).await?; + let team_association_id = DBTeam::get_association(id.into(), &**pool) + .await + .wrap_internal_err("fetching team from database")?; if let Some(TeamAssociationId::Project(pid)) = team_association_id { - let result = DBProject::get_id(pid, &**pool, &redis).await?; + let result = DBProject::get_id(pid, &**pool, &redis) + .await + .wrap_api_err("fetching project for ownership transfer")?; if let Some(project_item) = result && project_item.inner.organization_id.is_some() { - return Err(ApiError::InvalidInput( - "You cannot transfer ownership of a project team that is owned by an organization" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You cannot transfer ownership of a project team that is owned by an organization", + ))); } } @@ -1009,19 +1065,16 @@ pub async fn transfer_ownership( current_user.id.into(), &**pool, ) - .await? - .ok_or_else(|| { - ApiError::CustomAuthentication( - "You don't have permission to edit members of this team" - .to_string(), - ) + .await + .wrap_internal_err("fetching team member from database")? + .wrap_auth_err_with(|| { + "you don't have permission to edit members of this team".to_string() })?; 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::Auth(eyre::eyre!( + "You don't have permission to edit the ownership of this team", + ))); } } @@ -1030,25 +1083,28 @@ pub async fn transfer_ownership( new_owner.user_id.into(), &**pool, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The new owner specified does not exist".to_string(), - ) + .await + .wrap_internal_err("fetching team member from database")? + .wrap_request_err_with(|| { + "the new owner specified does not exist".to_string() })?; if !new_member.accepted { - return Err(ApiError::InvalidInput( - "You can only transfer ownership to members who are currently in your team".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You can only transfer ownership to members who are currently in your team", + ))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; // The following are the only places new_is_owner is modified. if let Some(former_owner) = DBTeamMember::get_from_team_full(id.into(), &**pool, &redis) - .await? + .await + .wrap_internal_err("fetching team member from database")? .into_iter() .find(|x| x.is_owner) { @@ -1064,7 +1120,8 @@ pub async fn transfer_ownership( Some(false), &mut transaction, ) - .await?; + .await + .wrap_internal_err("updating team member in database")?; } DBTeamMember::edit_team_member( @@ -1086,7 +1143,8 @@ pub async fn transfer_ownership( Some(true), &mut transaction, ) - .await?; + .await + .wrap_internal_err("updating database records for `transfer_ownership`")?; let project_teams_edited = if let Some(TeamAssociationId::Organization(oid)) = team_association_id @@ -1105,7 +1163,8 @@ pub async fn transfer_ownership( oid.0 as i64 ) .fetch_all(&mut transaction) - .await?; + .await + .wrap_internal_err("fetching team IDs from database")?; let team_ids: Vec = team_ids @@ -1120,7 +1179,8 @@ pub async fn transfer_ownership( new_owner.user_id.into(), &mut transaction, ) - .await?; + .await + .wrap_internal_err("deleting team member from database")?; } team_ids @@ -1138,14 +1198,24 @@ pub async fn transfer_ownership( }, } .insert(new_owner.user_id.into(), &mut transaction, &redis) - .await?; + .await + .wrap_internal_err( + "inserting database records for `transfer_ownership`", + )?; } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; - DBTeamMember::clear_cache(id.into(), &redis).await?; + DBTeamMember::clear_cache(id.into(), &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; for team_id in project_teams_edited { - DBTeamMember::clear_cache(team_id, &redis).await?; + DBTeamMember::clear_cache(team_id, &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; } Ok(HttpResponse::NoContent().body("")) @@ -1181,31 +1251,38 @@ pub async fn remove_team_member( &session_queue, Scopes::PROJECT_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - let team_association = - DBTeam::get_association(id, &**pool).await?.ok_or_else(|| { - ApiError::InvalidInput( - "The team specified does not exist".to_string(), - ) + let team_association = DBTeam::get_association(id, &**pool) + .await + .wrap_internal_err("fetching team from database")? + .wrap_request_err_with(|| { + "the team specified does not exist".to_string() })?; let member = DBTeamMember::get_from_user_id(id, current_user.id.into(), &**pool) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; let delete_member = - DBTeamMember::get_from_user_id_pending(id, user_id, &**pool).await?; + DBTeamMember::get_from_user_id_pending(id, user_id, &**pool) + .await + .wrap_internal_err("deleting team member from database")?; 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::Auth(eyre::eyre!( + "The owner can't be removed from a team", + ))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; // Organization attached to a project this team is attached to match team_association { @@ -1214,18 +1291,21 @@ pub async fn remove_team_member( DBOrganization::get_associated_organization_project_id( pid, &**pool, ) - .await?; - let organization_team_member = - if let Some(organization) = &organization { - DBTeamMember::get_from_user_id( - organization.team_id, - current_user.id.into(), - &**pool, - ) - .await? - } else { - None - }; + .await + .wrap_internal_err("fetching organization from database")?; + let organization_team_member = if let Some(organization) = + &organization + { + DBTeamMember::get_from_user_id( + organization.team_id, + current_user.id.into(), + &**pool, + ) + .await + .wrap_internal_err("fetching team member from database")? + } else { + None + }; let permissions = ProjectPermissions::get_permissions_by_role( ¤t_user.role, &member, @@ -1243,12 +1323,14 @@ pub async fn remove_team_member( // true as if the permission exists, but the member does not, they are part of an org { DBTeamMember::delete(id, user_id, &mut transaction) - .await?; + .await + .wrap_internal_err( + "deleting team member from database", + )?; } else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to remove a member from this team" - .to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to remove a member from this team", + ))); } } else if Some(delete_member.user_id) == member.as_ref().map(|m| m.user_id) @@ -1258,12 +1340,13 @@ pub async fn remove_team_member( // This is a pending invite rather than a member, so the // user being invited or team members with the MANAGE_INVITES // permission can remove it. - DBTeamMember::delete(id, user_id, &mut transaction).await?; + DBTeamMember::delete(id, user_id, &mut transaction) + .await + .wrap_internal_err("deleting team member from database")?; } else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to cancel a team invite" - .to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to cancel a team invite", + ))); } } TeamAssociationId::Organization(_) => { @@ -1282,12 +1365,14 @@ pub async fn remove_team_member( .contains(OrganizationPermissions::REMOVE_MEMBER) { DBTeamMember::delete(id, user_id, &mut transaction) - .await?; + .await + .wrap_internal_err( + "deleting team member from database", + )?; } else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to remove a member from this organization" - .to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to remove a member from this organization", + ))); } } else if Some(delete_member.user_id) == member.map(|m| m.user_id) @@ -1297,22 +1382,31 @@ pub async fn remove_team_member( // This is a pending invite rather than a member, so the // user being invited or team members with the MANAGE_INVITES // permission can remove it. - DBTeamMember::delete(id, user_id, &mut transaction).await?; + DBTeamMember::delete(id, user_id, &mut transaction) + .await + .wrap_internal_err("deleting team member from database")?; } else { - return Err(ApiError::CustomAuthentication( - "You do not have permission to cancel an organization invite".to_string(), - )); + return Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to cancel an organization invite", + ))); } } } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; - DBTeamMember::clear_cache(id, &redis).await?; - DBUser::clear_project_cache(&[delete_member.user_id], &redis).await?; + DBTeamMember::clear_cache(id, &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; + DBUser::clear_project_cache(&[delete_member.user_id], &redis) + .await + .wrap_internal_err("deleting user from database")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index cfca3fdb67..4d7d82167d 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -15,6 +15,8 @@ use crate::models::threads::{MessageBody, Thread, ThreadType}; use crate::models::users::User; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use futures::TryStreamExt; use serde::Deserialize; @@ -46,7 +48,7 @@ pub async fn is_authorized_thread( user_id as database::models::ids::DBUserId, ) .fetch_one(pool) - .await? + .await.wrap_internal_err("fetching report exists from database")? .exists; report_exists.unwrap_or(false) @@ -62,7 +64,7 @@ pub async fn is_authorized_thread( user_id as database::models::ids::DBUserId, ) .fetch_one(pool) - .await? + .await.wrap_internal_err("fetching project exists from database")? .exists; if !project_exists.unwrap_or(false) { @@ -72,7 +74,7 @@ pub async fn is_authorized_thread( user_id as database::models::ids::DBUserId, ) .fetch_one(pool) - .await? + .await.wrap_internal_err("fetching organization exists from database")? .exists; org_exists.unwrap_or(false) @@ -139,7 +141,7 @@ pub async fn filter_authorized_threads( }); }) .try_collect::>() - .await?; + .await.wrap_internal_err("fetching query results from database")?; } let mut org_project_thread_ids = check_threads @@ -171,7 +173,7 @@ pub async fn filter_authorized_threads( }); }) .try_collect::>() - .await?; + .await.wrap_internal_err("fetching query results from database")?; } let report_thread_ids = check_threads @@ -202,7 +204,8 @@ pub async fn filter_authorized_threads( }); }) .try_collect::>() - .await?; + .await + .wrap_internal_err("fetching query results from database")?; } } @@ -224,7 +227,8 @@ pub async fn filter_authorized_threads( let users: Vec = database::models::DBUser::get_many_ids(&user_ids, &***pool, redis) - .await? + .await + .wrap_internal_err("fetching users from database")? .into_iter() .map(From::from) .collect(); @@ -283,7 +287,9 @@ pub async fn thread_get( ) -> Result { let string = info.into_inner().0.into(); - let thread_data = database::models::DBThread::get(string, &**pool).await?; + let thread_data = database::models::DBThread::get(string, &**pool) + .await + .wrap_internal_err("fetching thread from database")?; let user = get_user_from_headers( &req, @@ -292,11 +298,14 @@ pub async fn thread_get( &session_queue, Scopes::THREAD_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; if let Some(mut data) = thread_data - && is_authorized_thread(&data, &user, &pool).await? + && is_authorized_thread(&data, &user, &pool) + .await + .wrap_api_err("checking thread authorization")? { let authors = &mut data.members; @@ -316,14 +325,15 @@ pub async fn thread_get( let users: Vec = database::models::DBUser::get_many_ids(authors, &**pool, &redis) - .await? + .await + .wrap_internal_err("fetching users from database")? .into_iter() .map(From::from) .collect(); return Ok(HttpResponse::Ok().json(Thread::from(data, users, &user))); } - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } #[derive(Deserialize)] @@ -361,20 +371,25 @@ pub async fn threads_get( &session_queue, Scopes::THREAD_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let thread_ids: Vec = - serde_json::from_str::>(&ids.ids)? + serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing JSON data")? .into_iter() .map(|x| x.into()) .collect(); let threads_data = - database::models::DBThread::get_many(&thread_ids, &**pool).await?; + database::models::DBThread::get_many(&thread_ids, &**pool) + .await + .wrap_internal_err("fetching threads from database")?; - let threads = - filter_authorized_threads(threads_data, &user, &pool, &redis).await?; + let threads = filter_authorized_threads(threads_data, &user, &pool, &redis) + .await + .wrap_api_err("filtering authorized threads")?; Ok(HttpResponse::Ok().json(threads)) } @@ -413,7 +428,8 @@ pub async fn thread_send_message( &session_queue, Scopes::THREAD_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; thread_send_message_internal( @@ -423,7 +439,8 @@ pub async fn thread_send_message( new_message.into_inner(), &redis, ) - .await?; + .await + .wrap_api_err("sending thread message")?; Ok(HttpResponse::NoContent().finish()) } @@ -446,15 +463,15 @@ pub async fn thread_send_message_internal( } = &new_message.body { if body.len() > 65536 { - return Err(ApiError::InvalidInput( - "Input body is too long!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Input body is too long!", + ))); } if *private && !user.role.is_mod() { - return Err(ApiError::InvalidInput( - "You are not allowed to send private messages!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You are not allowed to send private messages!", + ))); } if let Some(replying_to) = replying_to { @@ -462,37 +479,45 @@ pub async fn thread_send_message_internal( (*replying_to).into(), pool, ) - .await?; + .await + .wrap_internal_err("fetching thread message from database")?; if let Some(thread_message) = thread_message { if thread_message.thread_id != string { - return Err(ApiError::InvalidInput( - "Message replied to is from another thread!" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Message replied to is from another thread!", + ))); } } else { - return Err(ApiError::InvalidInput( - "Message replied to does not exist!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Message replied to does not exist!", + ))); } } is_private = *private; } else { - return Err(ApiError::InvalidInput( - "You may only send text messages through this route!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You may only send text messages through this route!", + ))); } - let result = database::models::DBThread::get(string, pool).await?; + let result = database::models::DBThread::get(string, pool) + .await + .wrap_internal_err("fetching thread from database")?; if let Some(thread) = result { - if !is_authorized_thread(&thread, user, pool).await? { - return Err(ApiError::NotFound); + if !is_authorized_thread(&thread, user, pool) + .await + .wrap_api_err("checking thread authorization")? + { + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let id = ThreadMessageBuilder { author_id: Some(user.id.into()), @@ -501,12 +526,16 @@ pub async fn thread_send_message_internal( hide_identity: user.role.is_mod(), } .insert(&mut transaction) - .await?; + .await + .wrap_internal_err( + "inserting database records for `thread_send_message_internal`", + )?; if let Some(project_id) = thread.project_id { let project = database::models::DBProject::get_id(project_id, pool, redis) - .await?; + .await + .wrap_api_err("fetching thread project")?; if let Some(project) = project && project.inner.status != ProjectStatus::Processing @@ -519,7 +548,8 @@ pub async fn thread_send_message_internal( pool, redis, ) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; NotificationBuilder { body: NotificationBody::ModeratorMessage { @@ -534,7 +564,8 @@ pub async fn thread_send_message_internal( &mut transaction, redis, ) - .await?; + .await + .wrap_internal_err("inserting database records for `thread_send_message_internal`")?; NotificationBuilder { body: NotificationBody::ModerationMessageReceived { @@ -546,18 +577,20 @@ pub async fn thread_send_message_internal( &mut transaction, redis, ) - .await?; + .await + .wrap_internal_err("inserting database records for `thread_send_message_internal`")?; } } else if let Some(report_id) = thread.report_id { let report = database::models::report_item::DBReport::get(report_id, pool) - .await?; + .await + .wrap_internal_err("fetching report from database")?; if let Some(report) = report { if report.closed && !user.role.is_mod() { - return Err(ApiError::InvalidInput( - "You may not reply to a closed report".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "You may not reply to a closed report", + ))); } if user.id != report.reporter.into() && !is_private { @@ -570,7 +603,8 @@ pub async fn thread_send_message_internal( }, } .insert(report.reporter, &mut transaction, redis) - .await?; + .await + .wrap_internal_err("inserting database records for `thread_send_message_internal`")?; } } } @@ -585,7 +619,8 @@ pub async fn thread_send_message_internal( &mut transaction, redis, ) - .await? + .await + .wrap_internal_err("fetching image from database")? { let image: Image = db_image.into(); if !matches!( @@ -593,9 +628,9 @@ pub async fn thread_send_message_internal( ImageContext::ThreadMessage { .. } ) || image.context.inner_id().is_some() { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "Image {image_id} is not unused and in the 'thread_message' context" - ))); + )))); } sqlx::query!( @@ -608,23 +643,30 @@ pub async fn thread_send_message_internal( image_id.0 as i64 ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `thread_send_message_internal`", + )?; image_item::DBImage::clear_cache(image.id.into(), redis) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; } else { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "Image {image_id} does not exist" - ))); + )))); } } } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(()) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -656,30 +698,36 @@ pub async fn message_delete( &session_queue, Scopes::THREAD_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let result = database::models::DBThreadMessage::get( info.into_inner().0.into(), &**pool, ) - .await?; + .await + .wrap_internal_err("fetching thread message from database")?; 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::Auth(eyre::eyre!( + "You cannot delete this message!", + ))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let context = ImageContext::ThreadMessage { thread_message_id: Some(thread.id.into()), }; let images = database::DBImage::get_many_contexted(context, &mut transaction) - .await?; + .await + .wrap_internal_err("fetching images from database")?; for image in images { let name = image.url.split(&format!("{}/", ENV.CDN_URL)).nth(1); if let Some(icon_path) = name { @@ -688,10 +736,12 @@ pub async fn message_delete( icon_path, FileHostPublicity::Public, // FIXME: Consider using private file storage? ) - .await?; + .await + .wrap_internal_err("deleting file from file host")?; } database::DBImage::remove(image.id, &mut transaction, &redis) - .await?; + .await + .wrap_internal_err("deleting image from database")?; } let private = thread.body.is_private(); @@ -700,11 +750,15 @@ pub async fn message_delete( private, &mut transaction, ) - .await?; - transaction.commit().await?; + .await + .wrap_internal_err("deleting thread message from database")?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index 59a452c4de..ce5215d4ce 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -1,3 +1,4 @@ +use crate::util::error::ApiContext as _; use std::{ cmp::Reverse, collections::{HashMap, HashSet}, @@ -24,10 +25,7 @@ use crate::{ users::{Badges, Role}, }, queue::session::AuthQueue, - util::{ - img::delete_old_images, routes::read_limited_from_payload, - validate::validation_errors_to_string, - }, + util::{img::delete_old_images, routes::read_limited_from_payload}, }; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use ariadne::ids::UserId; @@ -85,16 +83,21 @@ pub async fn all_projects( .map(|x| x.1) .ok(); let target_user = DBUser::get(&info.into_inner().0, &**pool, &redis) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_internal_err("fetching user from database")? + .wrap_not_found_err("resource not found")?; let user_project_ids = - DBUser::get_projects(target_user.id, &**pool, &redis).await?; - let organization_ids = - DBUser::get_organizations(target_user.id, &**pool).await?; + DBUser::get_projects(target_user.id, &**pool, &redis) + .await + .wrap_internal_err("fetching users from database")?; + let organization_ids = DBUser::get_organizations(target_user.id, &**pool) + .await + .wrap_internal_err("fetching users from database")?; let organizations_data = DBOrganization::get_many_ids(&organization_ids, &**pool, &redis) - .await?; + .await + .wrap_internal_err("fetching organizations from database")?; let team_ids = organizations_data .iter() @@ -104,7 +107,8 @@ pub async fn all_projects( crate::database::models::DBTeamMember::get_from_team_full_many( &team_ids, &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; let users = DBUser::get_many_ids( &teams_data .iter() @@ -113,7 +117,8 @@ pub async fn all_projects( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching users from database")?; let mut team_groups = HashMap::new(); for member in teams_data { @@ -126,7 +131,10 @@ pub async fn all_projects( let mut organizations = HashMap::new(); let mut visible_organization_ids = Vec::new(); for data in organizations_data { - if !is_visible_organization(&data, &user, &pool, &redis).await? { + if !is_visible_organization(&data, &user, &pool, &redis) + .await + .wrap_api_err("checking organization visibility")? + { continue; } @@ -173,7 +181,8 @@ pub async fn all_projects( &organization_id_values, ) .fetch_all(&**pool) - .await? + .await + .wrap_internal_err("fetching organization project IDs from database")? .into_iter() .map(|row| DBProjectId(row.id)) .collect::>(); @@ -186,9 +195,11 @@ pub async fn all_projects( .collect::>(); let projects_data = crate::database::DBProject::get_many_ids(&project_ids, &**pool, &redis) - .await?; - let projects = - filter_visible_projects(projects_data, &user, &pool, true).await?; + .await + .wrap_api_err("fetching user and organization projects")?; + let projects = filter_visible_projects(projects_data, &user, &pool, true) + .await + .wrap_api_err("filtering visible projects")?; Ok(web::Json(AllProjectsResponse { projects, @@ -217,13 +228,13 @@ pub async fn admin_user_email( Scopes::SESSION_ACCESS, ) .await - .map(|x| x.1)?; + .map(|x| x.1) + .wrap_auth_err("authenticating API request")?; 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::Auth(eyre::eyre!( + "You do not have permission to get a user from their email!", + ))); } let user_id = sqlx::query!( @@ -234,12 +245,11 @@ pub async fn admin_user_email( email.email ) .fetch_optional(&**pool) - .await? + .await + .wrap_internal_err("fetching user ID from database")? .map(|x| x.id) - .ok_or_else(|| { - ApiError::InvalidInput( - "The email provided is not associated with a user!".to_string(), - ) + .wrap_request_err_with(|| { + "the email provided is not associated with a user!".to_string() })?; let user = DBUser::get_id( @@ -247,12 +257,13 @@ pub async fn admin_user_email( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching user from database")?; if let Some(user) = user { Ok(HttpResponse::Ok().json(user)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -286,22 +297,28 @@ pub async fn projects_list( .map(|x| x.1) .ok(); - let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?; + let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")?; if let Some(id) = id_option.map(|x| x.id) { - let project_data = DBUser::get_projects(id, &**pool, &redis).await?; + let project_data = DBUser::get_projects(id, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")?; let projects: Vec<_> = crate::database::DBProject::get_many_ids( &project_data, &**pool, &redis, ) - .await?; - let projects = - filter_visible_projects(projects, &user, &pool, true).await?; + .await + .wrap_api_err("fetching organization projects")?; + let projects = filter_visible_projects(projects, &user, &pool, true) + .await + .wrap_api_err("filtering visible projects")?; Ok(HttpResponse::Ok().json(projects)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -329,7 +346,8 @@ pub async fn user_auth_get( &session_queue, Scopes::USER_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; if !scopes.contains(Scopes::USER_READ_EMAIL) { user.email = None; @@ -340,8 +358,9 @@ pub async fn user_auth_get( } if user.role.is_mod() { - let note = - DBModerationNote::get_user(user.id.into(), &**pool, &redis).await?; + let note = DBModerationNote::get_user(user.id.into(), &**pool, &redis) + .await + .wrap_internal_err("fetching moderation note from database")?; user.moderation_notes = Some(note.map(Into::into)); } @@ -402,9 +421,12 @@ pub async fn users_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let user_ids = serde_json::from_str::>(&ids.ids)?; + let user_ids = serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing JSON data")?; - let users_data = DBUser::get_many(&user_ids, &**pool, &redis).await?; + let users_data = DBUser::get_many(&user_ids, &**pool, &redis) + .await + .wrap_internal_err("fetching users from database")?; let auth_user = get_user_from_headers( &req, @@ -423,7 +445,8 @@ pub async fn users_get( &**pool, &redis, ) - .await? + .await + .wrap_internal_err("fetching moderation notes from database")? } else { HashMap::new() }; @@ -462,7 +485,9 @@ pub async fn user_get( redis: web::Data, session_queue: web::Data, ) -> Result { - let user_data = DBUser::get(&info.into_inner().0, &**pool, &redis).await?; + let user_data = DBUser::get(&info.into_inner().0, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")?; if let Some(data) = user_data { let auth_user = get_user_from_headers( @@ -495,14 +520,15 @@ pub async fn user_get( }; if is_mod { - let note = - DBModerationNote::get_user(user_id, &**pool, &redis).await?; + let note = DBModerationNote::get_user(user_id, &**pool, &redis) + .await + .wrap_internal_err("fetching moderation note from database")?; response.moderation_notes = Some(note.map(Into::into)); } Ok(HttpResponse::Ok().json(response)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -523,17 +549,27 @@ pub async fn user_notes_edit( &session_queue, Scopes::SESSION_ACCESS, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; - new_note.validate_not_empty()?; + new_note + .validate_not_empty() + .wrap_api_err("validating not empty")?; let expected_version = - crate::models::moderation_notes::parse_if_match_header(&req)?; + crate::models::moderation_notes::parse_if_match_header(&req) + .wrap_api_err( + "executing `moderation_notes::parse_if_match_header`", + )?; let user_data = DBUser::get(&info.into_inner().0, &**pool, &redis) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_internal_err("fetching user from database")? + .wrap_not_found_err("resource not found")?; - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; if let Some(expected) = expected_version { let updated = DBModerationNote::update( Some(user_data.id), @@ -544,12 +580,13 @@ pub async fn user_notes_edit( new_note.user_rating, &mut transaction, ) - .await?; + .await + .wrap_internal_err("updating moderation note in database")?; if updated.is_none() { - return Err(ApiError::PreconditionFailed( - "moderation note version does not match".to_string(), - )); + return Err(ApiError::PreconditionFailed(eyre::eyre!( + "moderation note version does not match", + ))); } } else { let updated = DBModerationNote::insert( @@ -560,17 +597,23 @@ pub async fn user_notes_edit( new_note.user_rating, &mut transaction, ) - .await?; + .await + .wrap_internal_err("inserting moderation note into database")?; if updated.is_none() { - return Err(ApiError::PreconditionRequired( - "moderation note version does not match".to_string(), - )); + return Err(ApiError::PreconditionRequired(eyre::eyre!( + "moderation note version does not match", + ))); } }; - transaction.commit().await?; - DBModerationNote::clear_user_cache(user_data.id, &redis).await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; + DBModerationNote::clear_user_cache(user_data.id, &redis) + .await + .wrap_internal_err("clearing cached moderation note in Redis")?; Ok(HttpResponse::NoContent().finish()) } @@ -595,24 +638,30 @@ pub async fn collections_list( .map(|x| x.1) .ok(); - let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?; + let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")?; if let Some(id) = id_option.map(|x| x.id) { - let collection_data = DBUser::get_collections(id, &**pool).await?; + let collection_data = DBUser::get_collections(id, &**pool) + .await + .wrap_internal_err("fetching user from database")?; let response: Vec<_> = crate::database::models::DBCollection::get_many( &collection_data, &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching collections from database")?; - let collections = - filter_visible_collections(response, &user, true).await?; + let collections = filter_visible_collections(response, &user, true) + .await + .wrap_api_err("filtering visible collections")?; Ok(HttpResponse::Ok().json(collections)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -636,16 +685,20 @@ pub async fn orgs_list( .map(|x| x.1) .ok(); - let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?; + let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")?; if let Some(id) = id_option.map(|x| x.id) { - let org_data = DBUser::get_organizations(id, &**pool).await?; + let org_data = DBUser::get_organizations(id, &**pool) + .await + .wrap_internal_err("fetching user from database")?; let organizations_data = crate::database::models::organization_item::DBOrganization::get_many_ids( &org_data, &**pool, &redis, ) - .await?; + .await.wrap_internal_err("fetching organizations from database")?; let team_ids = organizations_data .iter() @@ -656,13 +709,15 @@ pub async fn orgs_list( crate::database::models::DBTeamMember::get_from_team_full_many( &team_ids, &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching team members from database")?; let users = DBUser::get_many_ids( &teams_data.iter().map(|x| x.user_id).collect::>(), &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching users from database")?; let mut organizations = vec![]; let mut team_groups = HashMap::new(); @@ -671,7 +726,10 @@ pub async fn orgs_list( } for data in organizations_data { - if !is_visible_organization(&data, &user, &pool, &redis).await? { + if !is_visible_organization(&data, &user, &pool, &redis) + .await + .wrap_api_err("checking organization visibility")? + { continue; } @@ -709,7 +767,7 @@ pub async fn orgs_list( Ok(HttpResponse::Ok().json(organizations)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -759,24 +817,33 @@ pub async fn user_edit( &session_queue, Scopes::USER_WRITE, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; - new_user.validate().map_err(|err| { - ApiError::Validation(validation_errors_to_string(err, None)) - })?; + new_user + .validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; - let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?; + let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")?; if let Some(actual_user) = id_option { let id = actual_user.id; let user_id: UserId = id.into(); if user.id == user_id || user.role.is_mod() { - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; if let Some(username) = &new_user.username { let existing_user_id_option = - DBUser::get(username, &**pool, &redis).await?; + DBUser::get(username, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")?; if existing_user_id_option .map(|x| UserId::from(x.id)) @@ -792,11 +859,12 @@ pub async fn user_edit( id as crate::database::models::ids::DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `user_edit`")?; } else { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "Username {username} is taken!" - ))); + )))); } } @@ -811,15 +879,15 @@ pub async fn user_edit( id as crate::database::models::ids::DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("fetching bio from database")?; } 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::Auth(eyre::eyre!( + "You do not have the permissions to edit the role of this user!", + ))); } let role = role.to_string(); @@ -834,15 +902,15 @@ pub async fn user_edit( id as crate::database::models::ids::DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `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::Auth(eyre::eyre!( + "You do not have the permissions to edit the badges of this user!", + ))); } sqlx::query!( @@ -855,15 +923,15 @@ pub async fn user_edit( id as crate::database::models::ids::DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `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::Auth(eyre::eyre!( + "You do not have the permissions to edit the venmo handle of this user!", + ))); } sqlx::query!( @@ -876,7 +944,8 @@ pub async fn user_edit( id as crate::database::models::ids::DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `user_edit`")?; } if let Some(allow_friend_requests) = &user.allow_friend_requests { @@ -890,20 +959,27 @@ pub async fn user_edit( id as crate::database::models::ids::DBUserId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "fetching allow friend requests from database", + )?; } - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; DBUser::clear_caches(&[(id, Some(actual_user.username))], &redis) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::CustomAuthentication( - "You do not have permission to edit this user!".to_string(), - )) + Err(ApiError::Auth(eyre::eyre!( + "You do not have permission to edit this user!", + ))) } } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -960,16 +1036,18 @@ pub async fn user_icon_edit( &session_queue, Scopes::USER_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?; + let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")?; 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::Auth(eyre::eyre!( + "You don't have permission to edit this user's icon.", + ))); } delete_old_images( @@ -978,14 +1056,16 @@ pub async fn user_icon_edit( FileHostPublicity::Public, &**file_host, ) - .await?; + .await + .wrap_api_err("deleting old images")?; let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", ) - .await?; + .await + .wrap_api_err("executing `read_limited_from_payload`")?; let user_id: UserId = actual_user.id.into(); let upload_result = crate::util::img::upload_image_optimized( @@ -997,7 +1077,8 @@ pub async fn user_icon_edit( Some(1.0), &**file_host, ) - .await?; + .await + .wrap_api_err("uploading image")?; sqlx::query!( " @@ -1010,12 +1091,15 @@ pub async fn user_icon_edit( actual_user.id as crate::database::models::ids::DBUserId, ) .execute(&**pool) - .await?; - DBUser::clear_caches(&[(actual_user.id, None)], &redis).await?; + .await + .wrap_internal_err("querying database for `user_icon_edit`")?; + DBUser::clear_caches(&[(actual_user.id, None)], &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -1047,16 +1131,18 @@ pub async fn user_icon_delete( &session_queue, Scopes::USER_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?; + let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")?; 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::Auth(eyre::eyre!( + "You don't have permission to edit this user's icon.", + ))); } delete_old_images( @@ -1065,7 +1151,8 @@ pub async fn user_icon_delete( FileHostPublicity::Public, &**file_host, ) - .await?; + .await + .wrap_api_err("deleting old images")?; sqlx::query!( " @@ -1076,13 +1163,16 @@ pub async fn user_icon_delete( actual_user.id as crate::database::models::ids::DBUserId, ) .execute(&**pool) - .await?; + .await + .wrap_internal_err("querying database for `user_icon_delete`")?; - DBUser::clear_caches(&[(actual_user.id, None)], &redis).await?; + DBUser::clear_caches(&[(actual_user.id, None)], &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -1112,17 +1202,20 @@ pub async fn user_delete( &session_queue, Scopes::USER_DELETE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis) .await .wrap_internal_err("failed to get user")?; - let id = id_option.map(|x| x.id).ok_or(ApiError::NotFound)?; + let id = id_option + .map(|x| x.id) + .wrap_not_found_err("resource not found")?; 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::Auth(eyre::eyre!( + "You do not have permission to delete this user!", + ))); } let mut transaction = pool @@ -1142,7 +1235,7 @@ pub async fn user_delete( if result.is_some() { Ok(()) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -1172,31 +1265,37 @@ pub async fn user_follows( &session_queue, Scopes::USER_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?; + let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")?; 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::Auth(eyre::eyre!( + "You do not have permission to see the projects this user follows!", + ))); } - let project_ids = DBUser::get_follows(id, &**pool).await?; + let project_ids = DBUser::get_follows(id, &**pool) + .await + .wrap_internal_err("fetching users from database")?; let projects: Vec<_> = crate::database::DBProject::get_many_ids( &project_ids, &**pool, &redis, ) - .await? + .await + .wrap_api_err("fetching followed projects")? .into_iter() .map(Project::from) .collect(); Ok(HttpResponse::Ok().json(projects)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -1226,22 +1325,25 @@ pub async fn user_notifications( &session_queue, Scopes::NOTIFICATION_READ, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?; + let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis) + .await + .wrap_internal_err("fetching user from database")?; 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::Auth(eyre::eyre!( + "You do not have permission to see the notifications of this user!", + ))); } let mut notifications: Vec = crate::database::models::notification_item::DBNotification::get_many_user_exposed_on_site( id, &**pool, &redis, ) - .await? + .await.wrap_internal_err("fetching notifications from database")? .into_iter() .map(Into::into) .collect(); @@ -1249,6 +1351,6 @@ pub async fn user_notifications( notifications.sort_by_key(|b| Reverse(b.created)); Ok(HttpResponse::Ok().json(notifications)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } diff --git a/apps/labrinth/src/routes/v3/version_file.rs b/apps/labrinth/src/routes/v3/version_file.rs index efda1648ad..929416b0c5 100644 --- a/apps/labrinth/src/routes/v3/version_file.rs +++ b/apps/labrinth/src/routes/v3/version_file.rs @@ -10,6 +10,8 @@ use crate::models::teams::ProjectPermissions; use crate::queue::session::AuthQueue; use crate::routes::internal::delphi; use crate::routes::{FileHash, HashAlgorithm}; +use crate::util::error::ApiContext as _; +use crate::util::error::Context; use crate::{database, models}; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use dashmap::DashMap; @@ -91,25 +93,30 @@ pub async fn get_version_from_hash( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching version from database")?; if let Some(file) = file { let version = database::models::DBVersion::get(file.version_id, &**pool, &redis) - .await?; + .await + .wrap_internal_err("fetching version from database")?; if let Some(version) = version { if !is_visible_version(&version.inner, &user_option, &pool, &redis) - .await? + .await + .wrap_api_err("checking version visibility")? { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!( + "resource not found" + ))); } Ok(HttpResponse::Ok() .json(models::projects::Version::from(version))) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -217,20 +224,23 @@ pub async fn get_update_from_hash( &***pool, &redis, ) - .await? + .await + .wrap_internal_err("querying database for `get_update_from_hash`")? && let Some(project) = database::models::DBProject::get_id( file.project_id, &***pool, &redis, ) - .await? + .await + .wrap_api_err("fetching project for version file")? { let mut versions = database::models::DBVersion::get_many( &project.versions, &***pool, &redis, ) - .await? + .await + .wrap_internal_err("fetching versions from database")? .into_iter() .filter(|x| { let mut bool = true; @@ -259,9 +269,12 @@ pub async fn get_update_from_hash( if let Some(first) = versions.next_back() { if !is_visible_version(&first.inner, &user_option, &pool, &redis) - .await? + .await + .wrap_api_err("checking version visibility")? { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!( + "resource not found" + ))); } return Ok( @@ -269,7 +282,7 @@ pub async fn get_update_from_hash( ); } } - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } // Requests above with multiple versions below @@ -336,18 +349,21 @@ pub async fn get_versions_from_hashes( &***pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching versions from database")?; let version_ids = files.iter().map(|x| x.version_id).collect::>(); let versions_data = filter_visible_versions( database::models::DBVersion::get_many(&version_ids, &***pool, &redis) - .await?, + .await + .wrap_internal_err("fetching versions from database")?, &user_option, &pool, pool.as_ref(), &redis, ) - .await?; + .await + .wrap_api_err("filtering visible versions")?; let mut response = HashMap::new(); @@ -415,7 +431,8 @@ pub async fn get_projects_from_hashes( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching versions from database")?; let project_ids = files.iter().map(|x| x.project_id).collect::>(); @@ -425,12 +442,14 @@ pub async fn get_projects_from_hashes( &**pool, &redis, ) - .await?, + .await + .wrap_api_err("fetching projects for visibility filtering")?, &user_option, &pool, false, ) - .await?; + .await + .wrap_api_err("filtering visible projects")?; let mut response = HashMap::new(); @@ -531,7 +550,9 @@ pub async fn update_files( update_data: web::Json, ) -> Result>, ApiError> { let file_hashes_to_versions = - update_files_internal(pool, redis, update_data).await?; + update_files_internal(pool, redis, update_data) + .await + .wrap_api_err("updating files internal")?; let resp = file_hashes_to_versions .into_iter() .filter_map(|(hash, versions)| { @@ -557,7 +578,8 @@ async fn update_files_internal( &***pool, &redis, ) - .await?; + .await + .wrap_internal_err("updating versions in database")?; // TODO: de-hardcode this and actually use version fields system let update_version_ids = sqlx::query!( @@ -592,7 +614,8 @@ async fn update_files_internal( .push(database::models::DBVersionId(m.version_id)); async move { Ok(acc) } }) - .await?; + .await + .wrap_internal_err("fetching project version IDs from database")?; let versions = database::models::DBVersion::get_many( &update_version_ids @@ -602,7 +625,8 @@ async fn update_files_internal( &***pool, &redis, ) - .await?; + .await + .wrap_internal_err("updating versions in database")?; let mut response = HashMap::>::new(); for file in files { @@ -697,14 +721,16 @@ pub async fn update_individual_files( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("updating versions in database")?; let projects = database::models::DBProject::get_many_ids( &files.iter().map(|x| x.project_id).collect::>(), &**pool, &redis, ) - .await?; + .await + .wrap_api_err("fetching projects for version files")?; let all_versions = database::models::DBVersion::get_many( &projects .iter() @@ -713,7 +739,8 @@ pub async fn update_individual_files( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching versions from database")?; let mut response = HashMap::new(); @@ -766,7 +793,8 @@ pub async fn update_individual_files( &pool, &redis, ) - .await? + .await + .wrap_api_err("checking version visibility")? { response.insert( hash.clone(), @@ -830,7 +858,8 @@ pub async fn delete_file( &session_queue, Scopes::VERSION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let hash = info.into_inner().0.to_lowercase(); @@ -844,7 +873,8 @@ pub async fn delete_file( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching version from database")?; if let Some(row) = file { if !user.role.is_admin() { @@ -855,7 +885,7 @@ pub async fn delete_file( &**pool, ) .await - .map_err(ApiError::Database)?; + .wrap_internal_err("fetching version team member")?; let organization = database::models::DBOrganization::get_associated_organization_project_id( @@ -863,7 +893,7 @@ pub async fn delete_file( &**pool, ) .await - .map_err(ApiError::Database)?; + .wrap_internal_err("fetching project organization")?; let organization_team_member = if let Some(organization) = &organization @@ -875,7 +905,7 @@ pub async fn delete_file( &**pool, ) .await - .map_err(ApiError::Database)? + .wrap_internal_err("fetching organization team member")? } else { None }; @@ -888,28 +918,32 @@ 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::Auth(eyre::eyre!( + "You don't have permission to delete this file!", + ))); } } let version = database::models::DBVersion::get(row.version_id, &**pool, &redis) - .await?; + .await + .wrap_internal_err("fetching version from database")?; if let Some(version) = version { if version.files.len() < 2 { - return Err(ApiError::InvalidInput( - "Versions must have at least one file uploaded to them" - .to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "Versions must have at least one file uploaded to them", + ))); } - database::models::DBVersion::clear_cache(&version, &redis).await?; + database::models::DBVersion::clear_cache(&version, &redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; sqlx::query!( " @@ -919,7 +953,8 @@ pub async fn delete_file( row.id.0 ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `delete_file`")?; sqlx::query!( " @@ -929,23 +964,30 @@ pub async fn delete_file( row.id.0, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("querying database for `delete_file`")?; database::models::version_item::cleanup_unused_attribution_files_and_groups(&mut transaction) - .await?; + .await.wrap_internal_err("deleting version item from database")?; delphi::tech_review_sync::sync_project_tech_review_state( &[row.project_id], delphi::tech_review_sync::TechReviewExitReason::FileDeleted, &mut transaction, ) - .await?; + .await + .wrap_api_err( + "executing `tech_review_sync::sync_project_tech_review_state`", + )?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -1015,27 +1057,32 @@ pub async fn download_version( &**pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching version from database")?; if let Some(file) = file { let version = database::models::DBVersion::get(file.version_id, &**pool, &redis) - .await?; + .await + .wrap_internal_err("fetching version from database")?; if let Some(version) = version { if !is_visible_version(&version.inner, &user_option, &pool, &redis) - .await? + .await + .wrap_api_err("checking version visibility")? { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!( + "resource not found" + ))); } Ok(HttpResponse::TemporaryRedirect() .append_header(("Location", &*file.url)) .json(DownloadRedirect { url: file.url })) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs index c09489bec3..377071142c 100644 --- a/apps/labrinth/src/routes/v3/versions.rs +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -1,3 +1,4 @@ +use crate::util::error::ApiContext as _; use std::collections::HashMap; use super::ApiError; @@ -27,8 +28,8 @@ use crate::queue::file_scan::get_files_missing_attribution; use crate::queue::session::AuthQueue; use crate::routes::internal::delphi; use crate::search::SearchState; +use crate::util::error::Context; use crate::util::img; -use crate::util::validate::validation_errors_to_string; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use ariadne::ids::base62_impl::parse_base62; use itertools::Itertools; @@ -79,8 +80,9 @@ pub async fn version_project_get_helper( redis: web::Data, session_queue: web::Data, ) -> Result { - let result = - database::models::DBProject::get(&id.0, &***ro_pool, &redis).await?; + let result = database::models::DBProject::get(&id.0, &***ro_pool, &redis) + .await + .wrap_api_err("fetching project from database")?; let user_option = get_user_from_headers( &req, @@ -95,9 +97,10 @@ pub async fn version_project_get_helper( if let Some(project) = result { if !is_visible_project(&project.inner, &user_option, &pool, false) - .await? + .await + .wrap_api_err("checking project visibility")? { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } let versions = database::models::DBVersion::get_many( @@ -105,7 +108,8 @@ pub async fn version_project_get_helper( &***ro_pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching versions from database")?; let id_opt = parse_base62(&id.1).ok(); let version = versions.into_iter().find(|x| { @@ -115,7 +119,8 @@ pub async fn version_project_get_helper( if let Some(version) = version && is_visible_version(&version.inner, &user_option, &pool, &redis) - .await? + .await + .wrap_api_err("checking version visibility")? { let version_id = version.inner.id; let mut v = models::projects::Version::from(version); @@ -148,7 +153,7 @@ pub async fn version_project_get_helper( } } - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } #[derive(Serialize, Deserialize)] @@ -193,7 +198,8 @@ pub async fn versions_get( session_queue: web::Data, ) -> Result { let version_ids = - serde_json::from_str::>(&ids.ids)? + serde_json::from_str::>(&ids.ids) + .wrap_request_err("deserializing JSON data")? .into_iter() .map(|x| x.into()) .collect::>(); @@ -202,7 +208,8 @@ pub async fn versions_get( &***ro_pool, &redis, ) - .await?; + .await + .wrap_internal_err("fetching versions from database")?; let user_option = get_user_from_headers( &req, @@ -222,7 +229,8 @@ pub async fn versions_get( &ro_pool, &redis, ) - .await?; + .await + .wrap_api_err("filtering visible versions")?; if !ids.include_changelog { for version in &mut versions { @@ -282,7 +290,8 @@ pub async fn version_get_helper( ) -> Result, ApiError> { let version_data = database::models::DBVersion::get(id.into(), &***ro_pool, &redis) - .await?; + .await + .wrap_internal_err("fetching version from database")?; let user_option = get_user_from_headers( &req, @@ -296,7 +305,9 @@ pub async fn version_get_helper( .ok(); if let Some(data) = version_data - && is_visible_version(&data.inner, &user_option, &pool, &redis).await? + && is_visible_version(&data.inner, &user_option, &pool, &redis) + .await + .wrap_api_err("checking version visibility")? { let version_id = data.inner.id; let mut version = models::projects::Version::from(data); @@ -329,7 +340,7 @@ pub async fn version_get_helper( return Ok(web::Json(version)); } - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } #[derive(Serialize, Deserialize, Validate, Default, Debug)] @@ -431,7 +442,8 @@ pub async fn version_edit( search_state: web::Data, ) -> Result { let new_version: EditVersion = - serde_json::from_value(new_version.into_inner())?; + serde_json::from_value(new_version.into_inner()) + .wrap_request_err("deserializing JSON data")?; version_edit_helper( req, info.into_inner(), @@ -459,17 +471,20 @@ pub async fn version_edit_helper( &session_queue, Scopes::VERSION_WRITE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; - new_version.validate().map_err(|err| { - ApiError::Validation(validation_errors_to_string(err, None)) - })?; + new_version + .validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; let version_id = info.0.into(); - let result = - database::models::DBVersion::get(version_id, &**pool, &redis).await?; + let result = database::models::DBVersion::get(version_id, &**pool, &redis) + .await + .wrap_internal_err("fetching version from database")?; if let Some(version_item) = result { let team_member = @@ -479,14 +494,16 @@ pub async fn version_edit_helper( false, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching team member from database")?; let organization = DBOrganization::get_associated_organization_project_id( version_item.inner.project_id, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching organization from database")?; let organization_team_member = if let Some(organization) = &organization { @@ -495,7 +512,8 @@ pub async fn version_edit_helper( user.id.into(), &**pool, ) - .await? + .await + .wrap_internal_err("fetching team member from database")? } else { None }; @@ -508,13 +526,15 @@ 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::Auth(eyre::eyre!( + "You do not have the permissions to edit this version!", + ))); } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; if let Some(name) = &new_version.name { sqlx::query!( @@ -527,7 +547,10 @@ pub async fn version_edit_helper( version_id as database::models::ids::DBVersionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `version_edit_helper`", + )?; } if let Some(number) = &new_version.version_number { @@ -541,7 +564,10 @@ pub async fn version_edit_helper( version_id as database::models::ids::DBVersionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `version_edit_helper`", + )?; } if let Some(version_type) = &new_version.version_type { @@ -555,7 +581,8 @@ pub async fn version_edit_helper( version_id as database::models::ids::DBVersionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("fetching version type from database")?; } if let Some(dependencies) = &new_version.dependencies { @@ -566,7 +593,8 @@ pub async fn version_edit_helper( version_id as database::models::ids::DBVersionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("fetching dependencies from database")?; let builders = dependencies .iter() @@ -583,7 +611,10 @@ pub async fn version_edit_helper( version_item.inner.id, &mut transaction, ) - .await?; + .await + .wrap_internal_err( + "inserting database records for `version_edit_helper`", + )?; } if !new_version.fields.is_empty() { @@ -595,7 +626,8 @@ pub async fn version_edit_helper( let all_loaders = loader_fields::Loader::list(&mut transaction, &redis) - .await?; + .await + .wrap_internal_err("fetching loader from Redis")?; let loader_ids = version_item .loaders .iter() @@ -612,7 +644,8 @@ pub async fn version_edit_helper( &mut transaction, &redis, ) - .await? + .await + .wrap_internal_err("fetching loader field from Redis")? .into_iter() .filter(|lf| version_fields_names.contains(&lf.field)) .collect::>(); @@ -631,7 +664,10 @@ pub async fn version_edit_helper( &loader_field_ids ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `version_edit_helper`", + )?; let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields( @@ -639,18 +675,19 @@ pub async fn version_edit_helper( &mut transaction, &redis, ) - .await?; + .await + .wrap_internal_err( + "fetching loader field enum value from Redis", + )?; let mut version_fields = Vec::new(); for (vf_name, vf_value) in new_version.fields { let loader_field = loader_fields .iter() .find(|lf| lf.field == vf_name) - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Loader field '{vf_name}' does not exist for any loaders supplied." - )) - })?; + .wrap_request_err_with(|| format!( + "loader field `{vf_name}` does not exist for any loaders supplied" + ))?; let enum_variants = loader_field_enum_values .remove(&loader_field.id) .unwrap_or_default(); @@ -660,11 +697,15 @@ pub async fn version_edit_helper( vf_value.clone(), enum_variants, ) - .map_err(ApiError::InvalidInput)?; + .map_err(eyre::Report::msg) + .wrap_request_err("parsing version field")?; version_fields.push(vf); } VersionField::insert_many(version_fields, &mut transaction) - .await?; + .await + .wrap_internal_err( + "inserting database records for `version_edit_helper`", + )?; } if let Some(loaders) = &new_version.loaders { @@ -675,7 +716,8 @@ pub async fn version_edit_helper( version_id as database::models::ids::DBVersionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("fetching loaders from database")?; let mut loader_versions = Vec::new(); for loader in loaders { @@ -685,20 +727,24 @@ pub async fn version_edit_helper( &mut transaction, &redis, ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "No database entry for loader provided." - .to_string(), - ) - })?; + .await + .wrap_internal_err("fetching loader from Redis")? + .wrap_request_err_with( + || { + "no database entry for loader provided." + .to_string() + }, + )?; loader_versions.push(DBLoaderVersion { loader_id, version_id, }); } DBLoaderVersion::insert_many(loader_versions, &mut transaction) - .await?; + .await + .wrap_internal_err( + "inserting loader versions into database", + )?; } if let Some(featured) = &new_version.featured { @@ -712,7 +758,8 @@ pub async fn version_edit_helper( version_id as database::models::ids::DBVersionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err("fetching featured status from database")?; } if let Some(body) = &new_version.changelog { @@ -726,14 +773,17 @@ pub async fn version_edit_helper( version_id as database::models::ids::DBVersionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `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::Auth(eyre::eyre!( + "You don't have permission to set the downloads of this mod", + ))); } sqlx::query!( @@ -746,7 +796,10 @@ pub async fn version_edit_helper( version_id as database::models::ids::DBVersionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `version_edit_helper`", + )?; let diff = *downloads - (version_item.inner.downloads as u32); @@ -761,14 +814,17 @@ pub async fn version_edit_helper( as database::models::ids::DBProjectId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `version_edit_helper`", + )?; } if let Some(status) = &new_version.status { if !status.can_be_requested() { - return Err(ApiError::InvalidInput( - "The requested status cannot be set!".to_string(), - )); + return Err(ApiError::Request(eyre::eyre!( + "The requested status cannot be set!", + ))); } sqlx::query!( @@ -781,7 +837,10 @@ pub async fn version_edit_helper( version_id as database::models::ids::DBVersionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `version_edit_helper`", + )?; } if let Some(file_types) = &new_version.file_types { @@ -796,12 +855,15 @@ pub async fn version_edit_helper( file_type.algorithm ) .fetch_optional(&**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Specified file with hash {} does not exist.", + .await + .wrap_internal_err( + "querying database for `version_edit_helper`", + )? + .wrap_request_err_with(|| { + format!( + "specified file with hash `{}` does not exist", file_type.algorithm.clone() - )) + ) })?; sqlx::query!( @@ -814,7 +876,10 @@ pub async fn version_edit_helper( file_type.file_type.as_ref().map(|x| x.as_str()), ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `version_edit_helper`", + )?; } } @@ -829,7 +894,10 @@ pub async fn version_edit_helper( version_id as database::models::ids::DBVersionId, ) .execute(&mut transaction) - .await?; + .await + .wrap_internal_err( + "querying database for `version_edit_helper`", + )?; } // delete any images no longer in the changelog @@ -847,18 +915,24 @@ pub async fn version_edit_helper( &mut transaction, &redis, ) - .await?; + .await + .wrap_api_err("deleting unused images")?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; database::models::DBVersion::clear_cache(&version_item, &redis) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; database::models::DBProject::clear_cache( version_item.inner.project_id, None, Some(true), &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_version_changes( @@ -868,12 +942,12 @@ 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::Auth(eyre::eyre!( + "You do not have permission to edit this version!", + ))) } } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -949,8 +1023,9 @@ pub async fn version_list_internal( ) -> Result { let string = info.into_inner().0; - let result = - database::models::DBProject::get(&string, &***ro_pool, &redis).await?; + let result = database::models::DBProject::get(&string, &***ro_pool, &redis) + .await + .wrap_api_err("fetching project from database")?; let user_option = get_user_from_headers( &req, @@ -965,9 +1040,10 @@ pub async fn version_list_internal( if let Some(project) = result { if !is_visible_project(&project.inner, &user_option, &pool, false) - .await? + .await + .wrap_api_err("checking project visibility")? { - return Err(ApiError::NotFound); + return Err(ApiError::NotFound(eyre::eyre!("resource not found"))); } let loader_field_filters = filters.loader_fields.as_ref().map(|x| { @@ -982,7 +1058,8 @@ pub async fn version_list_internal( &***ro_pool, &redis, ) - .await? + .await + .wrap_internal_err("fetching versions from database")? .into_iter() .filter(|x| { let mut bool = true; @@ -1048,7 +1125,7 @@ pub async fn version_list_internal( &redis, ), ) - .await?; + .await.wrap_internal_err("fetching minecraft game version from Redis")?; let mut joined_filters = Vec::new(); for game_version in &game_versions { @@ -1093,7 +1170,8 @@ pub async fn version_list_internal( &ro_pool, &redis, ) - .await?; + .await + .wrap_api_err("filtering visible versions")?; if !filters.include_changelog { for version in &mut response { @@ -1103,7 +1181,7 @@ pub async fn version_list_internal( Ok(HttpResponse::Ok().json(response)) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } @@ -1154,16 +1232,16 @@ pub async fn version_delete( &session_queue, Scopes::VERSION_DELETE, ) - .await? + .await + .wrap_auth_err("authenticating API request")? .1; let id = info.into_inner().0; let version = database::models::DBVersion::get(id.into(), &**pool, &redis) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "The specified version does not exist!".to_string(), - ) + .await + .wrap_internal_err("fetching version from database")? + .wrap_request_err_with(|| { + "the specified version does not exist!".to_string() })?; if !user.role.is_admin() { @@ -1175,14 +1253,15 @@ pub async fn version_delete( &**pool, ) .await - .map_err(ApiError::Database)?; + .wrap_internal_err("fetching project team member")?; let organization = DBOrganization::get_associated_organization_project_id( version.inner.project_id, &**pool, ) - .await?; + .await + .wrap_internal_err("fetching organization from database")?; let organization_team_member = if let Some(organization) = &organization { @@ -1191,7 +1270,8 @@ pub async fn version_delete( user.id.into(), &**pool, ) - .await? + .await + .wrap_internal_err("fetching team member from database")? } else { None }; @@ -1203,14 +1283,16 @@ 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::Auth(eyre::eyre!( + "You do not have permission to delete versions in this team", + ))); } } - let mut transaction = pool.begin().await?; + let mut transaction = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let context = ImageContext::Version { version_id: Some(version.inner.id.into()), @@ -1219,9 +1301,12 @@ pub async fn version_delete( context, &mut transaction, ) - .await?; + .await + .wrap_internal_err("fetching images from database")?; for image in uploaded_images { - image_item::DBImage::remove(image.id, &mut transaction, &redis).await?; + image_item::DBImage::remove(image.id, &mut transaction, &redis) + .await + .wrap_internal_err("deleting image from database")?; } let result = database::models::DBVersion::remove_full( @@ -1229,16 +1314,23 @@ pub async fn version_delete( &redis, &mut transaction, ) - .await?; + .await + .wrap_internal_err("deleting version from database")?; delphi::tech_review_sync::sync_project_tech_review_state( &[version.inner.project_id], delphi::tech_review_sync::TechReviewExitReason::FileDeleted, &mut transaction, ) - .await?; + .await + .wrap_api_err( + "executing `tech_review_sync::sync_project_tech_review_state`", + )?; - transaction.commit().await?; + transaction + .commit() + .await + .wrap_internal_err("committing database transaction")?; database::models::DBProject::clear_cache( version.inner.project_id, @@ -1246,7 +1338,8 @@ pub async fn version_delete( Some(true), &redis, ) - .await?; + .await + .wrap_internal_err("clearing cached data from Redis")?; search_state .queue .push_version_changes( @@ -1257,6 +1350,6 @@ pub async fn version_delete( if result.is_some() { Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::NotFound) + Err(ApiError::NotFound(eyre::eyre!("resource not found"))) } } diff --git a/apps/labrinth/src/search/backend/elasticsearch/mod.rs b/apps/labrinth/src/search/backend/elasticsearch/mod.rs index 7e41cd93f9..b6474bd61d 100644 --- a/apps/labrinth/src/search/backend/elasticsearch/mod.rs +++ b/apps/labrinth/src/search/backend/elasticsearch/mod.rs @@ -4,6 +4,7 @@ //! This keeps version filters correlated without duplicating every version //! into its project document. +use crate::util::error::ApiContext as _; use async_trait::async_trait; use eyre::{Result, eyre}; use itertools::Itertools; @@ -388,7 +389,7 @@ impl Elasticsearch { .wrap_internal_err("failed to execute Elasticsearch search")?; let mut body = response_json(response, "execute Elasticsearch search") .await - .map_err(ApiError::Internal)?; + .wrap_internal_err("parsing Elasticsearch search response")?; if sort_only && !body["hits"]["hits"].is_array() { body["hits"]["hits"] = Value::Array(Vec::new()); } @@ -410,12 +411,15 @@ impl Elasticsearch { .wrap_internal_err("failed to open Elasticsearch point in time")?; let body = response_json(response, "open Elasticsearch point in time") .await - .map_err(ApiError::Internal)?; - body["id"].as_str().map(ToOwned::to_owned).ok_or_else(|| { - ApiError::Internal(eyre!( - "Elasticsearch point in time response did not contain an ID" - )) - }) + .wrap_internal_err( + "parsing Elasticsearch point-in-time response", + )?; + body["id"] + .as_str() + .map(ToOwned::to_owned) + .wrap_internal_err( + "finding ID in Elasticsearch point-in-time response", + ) } async fn close_point_in_time(&self, id: &str) -> Result<(), ApiError> { @@ -428,7 +432,9 @@ impl Elasticsearch { .wrap_internal_err("failed to close Elasticsearch point in time")?; response_json(response, "close Elasticsearch point in time") .await - .map_err(ApiError::Internal)?; + .wrap_internal_err( + "parsing Elasticsearch point-in-time response", + )?; Ok(()) } @@ -456,7 +462,9 @@ impl Elasticsearch { "execute Elasticsearch point in time search", ) .await - .map_err(ApiError::Internal)?; + .wrap_internal_err( + "parsing Elasticsearch point-in-time search response", + )?; if sort_only && !body["hits"]["hits"].is_array() { body["hits"]["hits"] = Value::Array(Vec::new()); } @@ -472,7 +480,10 @@ impl Elasticsearch { offset: usize, size: usize, ) -> Result { - let mut point_in_time_id = self.open_point_in_time(alias).await?; + let mut point_in_time_id = self + .open_point_in_time(alias) + .await + .wrap_api_err("executing `open_point_in_time`")?; let result = self .execute_deep_search_with_point_in_time( query, @@ -517,7 +528,10 @@ impl Elasticsearch { "id": point_in_time_id, "keep_alive": "1m" }); - let body = self.execute_point_in_time_search(&body, true).await?; + let body = self + .execute_point_in_time_search(&body, true) + .await + .wrap_api_err("executing `execute_point_in_time_search`")?; if let Some(id) = body["pit_id"].as_str() { *point_in_time_id = id.to_string(); } @@ -579,7 +593,10 @@ impl Elasticsearch { "id": point_in_time_id, "keep_alive": "1m" }); - let body = self.execute_point_in_time_search(&body, false).await?; + let body = self + .execute_point_in_time_search(&body, false) + .await + .wrap_api_err("executing `execute_point_in_time_search`")?; if let Some(id) = body["pit_id"].as_str() { *point_in_time_id = id.to_string(); } @@ -832,10 +849,13 @@ impl Elasticsearch { &self, info: &SearchRequest, ) -> Result { - let parsed = parse_search_request(info)?; + let parsed = parse_search_request(info) + .wrap_api_err("executing `parse_search_request`")?; let search_sort = - parse_search_index(parsed.index, info.new_filters.as_deref())?; - let filter = Self::build_filter(info)?; + parse_search_index(parsed.index, info.new_filters.as_deref()) + .wrap_api_err("executing `parse_search_index`")?; + let filter = Self::build_filter(info) + .wrap_api_err("executing `Self::build_filter`")?; let mut filters = vec![json!({"term": {"document_type": "project"}})]; if let Some(filter) = &filter { filters.push(filter.query.clone()); @@ -858,7 +878,8 @@ impl Elasticsearch { parsed.offset, parsed.hits_per_page, ) - .await? + .await + .wrap_api_err("executing `execute_deep_search`")? } else { let body = Self::search_body( &query, @@ -869,7 +890,9 @@ impl Elasticsearch { None, true, ); - self.execute_search(&alias, &body, false).await? + self.execute_search(&alias, &body, false) + .await + .wrap_api_err("executing `execute_search`")? }; let total_hits = body["hits"]["total"]["value"] .as_u64() diff --git a/apps/labrinth/src/search/backend/typesense/mod.rs b/apps/labrinth/src/search/backend/typesense/mod.rs index e65232106c..6c7e407730 100644 --- a/apps/labrinth/src/search/backend/typesense/mod.rs +++ b/apps/labrinth/src/search/backend/typesense/mod.rs @@ -137,6 +137,7 @@ //! enabled. This avoids repeating joins for popular queries at the cost of //! results remaining stale for Typesense's default 60-second cache lifetime. +use crate::util::error::ApiContext as _; use std::sync::LazyLock; use async_trait::async_trait; @@ -911,7 +912,8 @@ impl Typesense { new_filters: Option<&str>, request_config: &RequestConfig, ) -> Result<(String, String), ApiError> { - let sort = parse_search_index(index, new_filters)?; + let sort = parse_search_index(index, new_filters) + .wrap_api_err("executing `parse_search_index`")?; let alias = self.config.get_alias_name("projects"); Ok((alias, self.get_sort_fields(sort.index, request_config))) } @@ -1047,14 +1049,18 @@ impl SearchBackend for Typesense { &self, info: &SearchRequest, ) -> Result { - let parsed = parse_search_request(info)?; - let (collection_alias, sort_by) = self.get_sort_index( - parsed.index, - info.new_filters.as_deref(), - &info.typesense_config, - )?; + let parsed = parse_search_request(info) + .wrap_api_err("executing `parse_search_request`")?; + let (collection_alias, sort_by) = self + .get_sort_index( + parsed.index, + info.new_filters.as_deref(), + &info.typesense_config, + ) + .wrap_api_err("fetching sort index")?; let versions_alias = self.config.get_alias_name("versions"); - let filter_by = Self::build_filter(info, &versions_alias)?; + let filter_by = Self::build_filter(info, &versions_alias) + .wrap_api_err("executing `Self::build_filter`")?; let q = if parsed.query.is_empty() { "*" diff --git a/apps/labrinth/src/search/mod.rs b/apps/labrinth/src/search/mod.rs index 690efe149a..9df4220e71 100644 --- a/apps/labrinth/src/search/mod.rs +++ b/apps/labrinth/src/search/mod.rs @@ -4,6 +4,8 @@ use crate::models::ids::{ProjectId, VersionId}; use crate::models::projects::DependencyType; use crate::queue::server_ping; use crate::routes::ApiError; +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use crate::{database::PgPool, env::ENV}; use ariadne::ids::base62_impl::parse_base62; use async_trait::async_trait; @@ -94,10 +96,13 @@ pub trait SearchBackend: Send + Sync { info: &SearchRequest, redis: &RedisPool, ) -> Result { - let mut results = self.search_for_project_raw(info).await?; + let mut results = self + .search_for_project_raw(info) + .await + .wrap_api_err("searching projects")?; hydrate_search_results(&mut results.hits, redis) .await - .map_err(ApiError::Internal)?; + .wrap_internal_err("hydrating search results from database")?; Ok(results) } diff --git a/apps/labrinth/src/util/avalara1099.rs b/apps/labrinth/src/util/avalara1099.rs index d36e5edf62..8b9ebfb329 100644 --- a/apps/labrinth/src/util/avalara1099.rs +++ b/apps/labrinth/src/util/avalara1099.rs @@ -1,6 +1,8 @@ use crate::database::models::{DBUserId, users_compliance::FormType}; use crate::env::ENV; use crate::routes::ApiError; +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use ariadne::ids::base62_impl::to_base62; use chrono::Datelike; use serde::{Deserialize, Serialize}; @@ -62,7 +64,8 @@ pub async fn request_form( } let (request_builder, company_id) = - team_request(reqwest::Method::POST, "/form_requests")?; + team_request(reqwest::Method::POST, "/form_requests") + .wrap_api_err("executing `team_request`")?; let response = request_builder .json(&DataWrapper { @@ -84,12 +87,19 @@ pub async fn request_form( }, }) .send() - .await?; + .await + .wrap_internal_err("deserializing HTTP response")?; Ok(if response.status().is_success() { - Ok(response.json::>().await?) + Ok(response + .json::>() + .await + .wrap_internal_err("deserializing HTTP response")?) } else { - Err(response.json().await?) + Err(response + .json() + .await + .wrap_internal_err("deserializing HTTP response")?) }) } @@ -104,12 +114,18 @@ pub async fn check_form( &format!( "/w9forms?filter[reference_id_eq]={reference_id}&page[number]=1&page[size]=1" ), - )?; + ).wrap_api_err("executing `team_request`")?; - let response = request_builder.send().await?; + let response = request_builder + .send() + .await + .wrap_internal_err("sending HTTP request")?; Ok(if response.status().is_success() { - let body = response.text().await?; + let body = response + .text() + .await + .wrap_internal_err("reading HTTP response body")?; let serde_result = serde_json::from_str::>(&body); @@ -118,13 +134,16 @@ pub async fn check_form( Ok(list_wrapper.data.pop().map(|data| DataWrapper { data })) } Err(e) => { - return Err(ApiError::InvalidInput(format!( + return Err(ApiError::Request(eyre::eyre!(format!( "Error parsing avalara1099 response: {e}. Actual response body: {body}" - ))); + )))); } } } else { - Err(response.json().await?) + Err(response + .json() + .await + .wrap_internal_err("deserializing HTTP response")?) }) } diff --git a/apps/labrinth/src/util/captcha.rs b/apps/labrinth/src/util/captcha.rs index af32e51927..b2cabe52c0 100644 --- a/apps/labrinth/src/util/captcha.rs +++ b/apps/labrinth/src/util/captcha.rs @@ -1,5 +1,6 @@ use crate::env::ENV; use crate::routes::ApiError; +use crate::util::error::Context as _; use actix_web::HttpRequest; use serde::Deserialize; use std::collections::HashMap; @@ -26,7 +27,7 @@ pub async fn check_hcaptcha( conn_info.peer_addr() }; - let ip_addr = ip_addr.ok_or(ApiError::Turnstile)?; + let ip_addr = ip_addr.wrap_request_err("captcha validation failed")?; let client = reqwest::Client::new(); @@ -46,10 +47,11 @@ pub async fn check_hcaptcha( .form(&form) .send() .await - .map_err(|_| ApiError::Turnstile)? + .wrap_request_err("captcha validation failed")? .json() .await - .map_err(|_| ApiError::Turnstile)?; + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("captcha validation failed")?; Ok(val.success) } diff --git a/apps/labrinth/src/util/error.rs b/apps/labrinth/src/util/error.rs index 5f9ff343c2..8b52a20c7b 100644 --- a/apps/labrinth/src/util/error.rs +++ b/apps/labrinth/src/util/error.rs @@ -5,6 +5,31 @@ use std::{ use crate::routes::ApiError; +/// Adds context to an [`ApiError`] while preserving its HTTP status variant. +pub trait ApiContext: Sized { + /// Wraps the report held by the error variant with a lazily-created message. + fn wrap_api_err_with(self, f: impl FnOnce() -> D) -> Result + where + D: Send + Sync + Debug + Display + 'static; + + /// Wraps the report held by the error variant with the given message. + fn wrap_api_err(self, msg: D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_api_err_with(|| msg) + } +} + +impl ApiContext for Result { + fn wrap_api_err_with(self, f: impl FnOnce() -> D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.map_err(|error| error.wrap_err(f())) + } +} + /// Allows wrapping [`Result`]s and [`Option`]s into [`Result`]s. #[allow( clippy::missing_errors_doc, @@ -85,6 +110,133 @@ pub trait Context: Sized { { self.wrap_auth_err_with(|| msg) } + + /// Maps the error variant into an [`ApiError::NotFound`] using the closure to create the message. + #[inline] + fn wrap_not_found_err_with( + self, + f: impl FnOnce() -> D, + ) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_err_with(f).map_err(ApiError::NotFound) + } + + /// Maps the error variant into an [`ApiError::NotFound`] with the given message. + #[inline] + fn wrap_not_found_err(self, msg: D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_not_found_err_with(|| msg) + } + + /// Maps the error variant into an [`ApiError::Conflict`] using the closure to create the message. + #[inline] + fn wrap_conflict_err_with( + self, + f: impl FnOnce() -> D, + ) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_err_with(f).map_err(ApiError::Conflict) + } + + /// Maps the error variant into an [`ApiError::Conflict`] with the given message. + #[inline] + fn wrap_conflict_err(self, msg: D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_conflict_err_with(|| msg) + } + + /// Maps the error variant into an [`ApiError::FailedDependency`] using the closure to create the message. + #[inline] + fn wrap_failed_dependency_err_with( + self, + f: impl FnOnce() -> D, + ) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_err_with(f).map_err(ApiError::FailedDependency) + } + + /// Maps the error variant into an [`ApiError::FailedDependency`] with the given message. + #[inline] + fn wrap_failed_dependency_err(self, msg: D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_failed_dependency_err_with(|| msg) + } + + /// Maps the error variant into an [`ApiError::PreconditionRequired`] using the closure to create the message. + #[inline] + fn wrap_precondition_required_err_with( + self, + f: impl FnOnce() -> D, + ) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_err_with(f) + .map_err(ApiError::PreconditionRequired) + } + + /// Maps the error variant into an [`ApiError::PreconditionRequired`] with the given message. + #[inline] + fn wrap_precondition_required_err(self, msg: D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_precondition_required_err_with(|| msg) + } + + /// Maps the error variant into an [`ApiError::PreconditionFailed`] using the closure to create the message. + #[inline] + fn wrap_precondition_failed_err_with( + self, + f: impl FnOnce() -> D, + ) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_err_with(f).map_err(ApiError::PreconditionFailed) + } + + /// Maps the error variant into an [`ApiError::PreconditionFailed`] with the given message. + #[inline] + fn wrap_precondition_failed_err(self, msg: D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_precondition_failed_err_with(|| msg) + } + + /// Maps the error variant into an [`ApiError::RateLimit`] using the closure to create the message. + #[inline] + fn wrap_rate_limit_err_with( + self, + f: impl FnOnce() -> D, + ) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_err_with(f).map_err(ApiError::RateLimit) + } + + /// Maps the error variant into an [`ApiError::RateLimit`] with the given message. + #[inline] + fn wrap_rate_limit_err(self, msg: D) -> Result + where + D: Send + Sync + Debug + Display + 'static, + { + self.wrap_rate_limit_err_with(|| msg) + } } impl Context for Result @@ -146,6 +298,40 @@ mod tests { let auth_error = ApiError::Auth(eyre::eyre!("auth error")); assert_eq!(auth_error.status_code(), StatusCode::UNAUTHORIZED); + + let not_found_error = + ApiError::NotFound(eyre::eyre!("not found error")); + assert_eq!(not_found_error.status_code(), StatusCode::NOT_FOUND); + + let conflict_error = ApiError::Conflict(eyre::eyre!("conflict error")); + assert_eq!(conflict_error.status_code(), StatusCode::CONFLICT); + + let dependency_error = + ApiError::FailedDependency(eyre::eyre!("dependency error")); + assert_eq!( + dependency_error.status_code(), + StatusCode::FAILED_DEPENDENCY + ); + + let required_error = ApiError::PreconditionRequired(eyre::eyre!( + "precondition required error" + )); + assert_eq!( + required_error.status_code(), + StatusCode::PRECONDITION_REQUIRED + ); + + let failed_error = ApiError::PreconditionFailed(eyre::eyre!( + "precondition failed error" + )); + assert_eq!(failed_error.status_code(), StatusCode::PRECONDITION_FAILED); + + let rate_limit_error = + ApiError::RateLimit(eyre::eyre!("rate limit error")); + assert_eq!( + rate_limit_error.status_code(), + StatusCode::TOO_MANY_REQUESTS + ); } #[test] @@ -237,6 +423,68 @@ mod tests { } } + #[test] + fn test_context_trait_status_errors() { + let not_found: Option = None; + assert!(matches!( + not_found.wrap_not_found_err("missing value").unwrap_err(), + ApiError::NotFound(_) + )); + + let conflict: Option = None; + assert!(matches!( + conflict.wrap_conflict_err("conflicting value").unwrap_err(), + ApiError::Conflict(_) + )); + + let dependency: Option = None; + assert!(matches!( + dependency + .wrap_failed_dependency_err("dependency failed") + .unwrap_err(), + ApiError::FailedDependency(_) + )); + + let required: Option = None; + assert!(matches!( + required + .wrap_precondition_required_err("precondition required") + .unwrap_err(), + ApiError::PreconditionRequired(_) + )); + + let failed: Option = None; + assert!(matches!( + failed + .wrap_precondition_failed_err("precondition failed") + .unwrap_err(), + ApiError::PreconditionFailed(_) + )); + + let rate_limit: Option = None; + assert!(matches!( + rate_limit + .wrap_rate_limit_err("rate limit exceeded") + .unwrap_err(), + ApiError::RateLimit(_) + )); + } + + #[test] + fn test_api_context_preserves_status_variant() { + let result: Result<(), ApiError> = + Err(ApiError::NotFound(eyre::eyre!("missing value"))); + let error = result.wrap_api_err("fetching test value").unwrap_err(); + + match error { + ApiError::NotFound(report) => { + assert_eq!(report.to_string(), "fetching test value"); + assert!(format!("{report:#}").contains("missing value")); + } + _ => panic!("expected NotFound error"), + } + } + #[test] fn test_context_trait_with_closure() { let result: Result = Err(std::io::Error::new( diff --git a/apps/labrinth/src/util/gotenberg.rs b/apps/labrinth/src/util/gotenberg.rs index 64d971b801..b672c1798c 100644 --- a/apps/labrinth/src/util/gotenberg.rs +++ b/apps/labrinth/src/util/gotenberg.rs @@ -2,6 +2,7 @@ use crate::env::ENV; use crate::models::ids::PayoutId; use crate::routes::ApiError; use crate::routes::internal::gotenberg::{GotenbergDocument, GotenbergError}; +use crate::util::error::ApiContext as _; use crate::util::error::Context; use actix_web::http::header::HeaderName; use chrono::{DateTime, Datelike, Utc}; @@ -166,7 +167,7 @@ impl GotenbergClient { .await .wrap_internal_err("failed to submit HTML to Gotenberg")? .error_for_status() - .wrap_internal_err("Gotenberg returned an error status")?; + .wrap_internal_err("received an error status from Gotenberg")?; Ok(()) } @@ -190,7 +191,9 @@ impl GotenbergClient { &self, statement: &PaymentStatement, ) -> Result { - self.generate_payment_statement(statement).await?; + self.generate_payment_statement(statement) + .await + .wrap_api_err("executing `generate_payment_statement`")?; let timeout_ms = ENV.GOTENBERG_TIMEOUT; let redis_timeout_ms = @@ -205,7 +208,7 @@ impl GotenbergClient { .brpop(&response_key, Duration::from_millis(redis_timeout_ms)), ) .await - .wrap_internal_err("Gotenberg document generation timed out")? + .wrap_internal_err("timed out generating Gotenberg document")? .wrap_internal_err("failed to get document over Redis")? .wrap_internal_err("no document was returned from Redis")?; @@ -213,7 +216,7 @@ impl GotenbergClient { Result, >(&document) .wrap_internal_err("failed to deserialize Redis document response")? - .wrap_internal_err("Gotenberg document generation failed")?; + .wrap_internal_err("failed to generate Gotenberg document")?; Ok(document) } diff --git a/apps/labrinth/src/util/img.rs b/apps/labrinth/src/util/img.rs index ecbecb3470..613d86666e 100644 --- a/apps/labrinth/src/util/img.rs +++ b/apps/labrinth/src/util/img.rs @@ -4,6 +4,7 @@ use crate::env::ENV; use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::images::ImageContext; use crate::routes::ApiError; +use crate::util::error::Context as _; use color_thief::ColorFormat; use hex::ToHex; use image::imageops::FilterType; @@ -54,10 +55,8 @@ pub async fn upload_image_optimized( file_host: &dyn FileHost, ) -> Result { let content_type = crate::util::ext::get_image_content_type(file_extension) - .ok_or_else(|| { - ApiError::InvalidInput(format!( - "Invalid format for image: {file_extension}" - )) + .wrap_request_err_with(|| { + format!("invalid format for image: {file_extension}") })?; let cdn_url = &ENV.CDN_URL; @@ -68,8 +67,10 @@ pub async fn upload_image_optimized( content_type, target_width, min_aspect_ratio, - )?; - let color = get_color_from_img(&bytes)?; + ) + .wrap_request_err("processing uploaded image")?; + let color = get_color_from_img(&bytes) + .wrap_request_err("extracting color from uploaded image")?; // Only upload the processed image if it's smaller than the original let processed_upload_data = if processed_image.len() < bytes.len() { @@ -87,7 +88,8 @@ pub async fn upload_image_optimized( publicity, processed_image, ) - .await?, + .await + .wrap_internal_err("uploading file to file host")?, ) } else { None @@ -100,7 +102,8 @@ pub async fn upload_image_optimized( publicity, bytes, ) - .await?; + .await + .wrap_internal_err("uploading file to file host")?; let url = format!("{}/{}", cdn_url, upload_data.file_name); Ok(UploadImageResult { @@ -182,7 +185,10 @@ pub async fn delete_old_images( let name = image_url.split(&cdn_url_start).nth(1); if let Some(icon_path) = name { - file_host.delete_file(icon_path, publicity).await?; + file_host + .delete_file(icon_path, publicity) + .await + .wrap_internal_err("deleting file from file host")?; } } @@ -190,7 +196,10 @@ pub async fn delete_old_images( let name = raw_image_url.split(&cdn_url_start).nth(1); if let Some(icon_path) = name { - file_host.delete_file(icon_path, publicity).await?; + file_host + .delete_file(icon_path, publicity) + .await + .wrap_internal_err("deleting file from file host")?; } } @@ -208,7 +217,8 @@ pub async fn delete_unused_images( ) -> Result<(), ApiError> { let uploaded_images = database::models::DBImage::get_many_contexted(context, transaction) - .await?; + .await + .wrap_internal_err("fetching images from database")?; for image in uploaded_images { let mut should_delete = true; @@ -220,8 +230,12 @@ pub async fn delete_unused_images( } if should_delete { - image_item::DBImage::remove(image.id, transaction, redis).await?; - image_item::DBImage::clear_cache(image.id, redis).await?; + image_item::DBImage::remove(image.id, transaction, redis) + .await + .wrap_internal_err("deleting image from database")?; + image_item::DBImage::clear_cache(image.id, redis) + .await + .wrap_internal_err("clearing cached data from Redis")?; } } diff --git a/apps/labrinth/src/util/ratelimit.rs b/apps/labrinth/src/util/ratelimit.rs index 9088b6ad50..141b57cbeb 100644 --- a/apps/labrinth/src/util/ratelimit.rs +++ b/apps/labrinth/src/util/ratelimit.rs @@ -178,10 +178,10 @@ pub async fn rate_limit_middleware( Ok(service_response.map_into_left_body()) } else { - let mut response = ApiError::RateLimitError( - decision.retry_after_ms.unwrap_or(0) as u128, - decision.limit, - ) + let retry_after_ms = decision.retry_after_ms.unwrap_or(0); + let mut response = ApiError::RateLimit(eyre::eyre!( + "rate limit exceeded; retry after {retry_after_ms} milliseconds" + )) .error_response(); // Add rate limit headers @@ -220,10 +220,9 @@ 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(), - ) - .error_response(); + let response = + ApiError::Auth(eyre::eyre!("Unable to obtain user IP address!",)) + .error_response(); Ok(req.into_response(response.map_into_right_body())) } diff --git a/apps/labrinth/src/util/routes.rs b/apps/labrinth/src/util/routes.rs index c963937213..c753988375 100644 --- a/apps/labrinth/src/util/routes.rs +++ b/apps/labrinth/src/util/routes.rs @@ -1,6 +1,6 @@ use crate::routes::ApiError; use crate::routes::v3::project_creation::CreateError; -use crate::util::validate::validation_errors_to_string; +use crate::util::error::Context as _; use actix_multipart::Field; use actix_web::web::Payload; use bytes::BytesMut; @@ -16,13 +16,13 @@ pub async fn read_limited_from_payload( let mut bytes = BytesMut::new(); while let Some(item) = payload.next().await { if bytes.len() >= cap { - return Err(ApiError::InvalidInput(String::from(err_msg))); + return Err(ApiError::Request(eyre::eyre!(String::from(err_msg)))); } else { - bytes.extend_from_slice(&item.map_err(|_| { - ApiError::InvalidInput( - "Unable to parse bytes in payload sent!".to_string(), - ) - })?); + bytes.extend_from_slice( + &item.map_err(|err| eyre::eyre!(err)).wrap_request_err( + "unable to parse bytes in payload sent!".to_string(), + )?, + ); } } Ok(bytes) @@ -36,17 +36,19 @@ where { let mut bytes = BytesMut::new(); while let Some(item) = payload.next().await { - bytes.extend_from_slice(&item.map_err(|_| { - ApiError::InvalidInput( - "Unable to parse bytes in payload sent!".to_string(), - ) - })?); + bytes.extend_from_slice( + &item.map_err(|err| eyre::eyre!(err)).wrap_request_err( + "unable to parse bytes in payload sent!".to_string(), + )?, + ); } - let parsed: T = serde_json::from_slice(&bytes)?; - parsed.validate().map_err(|err| { - ApiError::InvalidInput(validation_errors_to_string(err, None)) - })?; + let parsed: T = serde_json::from_slice(&bytes) + .wrap_request_err("deserializing JSON data")?; + parsed + .validate() + .map_err(|err| eyre::eyre!(err)) + .wrap_request_err("validating request")?; Ok(parsed) } diff --git a/apps/labrinth/src/util/webhook.rs b/apps/labrinth/src/util/webhook.rs index aa0978ee5a..8ce75b6afe 100644 --- a/apps/labrinth/src/util/webhook.rs +++ b/apps/labrinth/src/util/webhook.rs @@ -1,6 +1,8 @@ use crate::database::models::legacy_loader_fields::MinecraftGameVersion; use crate::models::ids::ProjectId; use crate::routes::ApiError; +use crate::util::error::ApiContext as _; +use crate::util::error::Context as _; use crate::{database::PgPool, env::ENV}; use ariadne::ids::base62_impl::to_base62; use chrono::{DateTime, Utc}; @@ -51,7 +53,8 @@ async fn get_webhook_metadata( pool, redis, ) - .await?; + .await + .wrap_api_err("fetching webhook project")?; if let Some(mut project) = project { let mut owner = None; @@ -62,7 +65,7 @@ async fn get_webhook_metadata( pool, redis, ) - .await?; + .await.wrap_internal_err("fetching organization from database")?; if let Some(organization) = organization { owner = Some(WebhookAuthor { @@ -81,7 +84,7 @@ async fn get_webhook_metadata( pool, redis, ) - .await?; + .await.wrap_internal_err("fetching team member from database")?; if let Some(member) = team.into_iter().find(|x| x.is_owner) { let user = crate::database::models::user_item::DBUser::get_id( @@ -89,7 +92,8 @@ async fn get_webhook_metadata( pool, redis, ) - .await?; + .await + .wrap_internal_err("fetching user from database")?; if let Some(user) = user { owner = Some(WebhookAuthor { @@ -106,7 +110,11 @@ async fn get_webhook_metadata( }; let all_game_versions = - MinecraftGameVersion::list(None, None, pool, redis).await?; + MinecraftGameVersion::list(None, None, pool, redis) + .await + .wrap_internal_err( + "fetching minecraft game version from Redis", + )?; let versions = project .aggregate_version_fields @@ -249,9 +257,7 @@ pub async fn send_slack_payout_source_alert_webhook( })) .send() .await - .map_err(|_| { - ApiError::Slack("Error while sending projects webhook".to_string()) - })?; + .map_err(|err| eyre::eyre!(err)).wrap_internal_err("error while sending projects webhook".to_string())?; Ok(()) } @@ -263,7 +269,9 @@ pub async fn send_slack_project_webhook( webhook_url: &str, message: Option, ) -> Result<(), ApiError> { - let metadata = get_webhook_metadata(project_id, pool, redis).await?; + let metadata = get_webhook_metadata(project_id, pool, redis) + .await + .wrap_api_err("fetching webhook metadata")?; if let Some(metadata) = metadata { let mut blocks = vec![]; @@ -365,11 +373,10 @@ pub async fn send_slack_project_webhook( })) .send() .await - .map_err(|_| { - ApiError::Slack( - "Error while sending projects webhook".to_string(), - ) - })?; + .map_err(|err| eyre::eyre!(err)) + .wrap_internal_err( + "error while sending projects webhook".to_string(), + )?; } Ok(()) @@ -434,7 +441,9 @@ pub async fn send_discord_webhook( webhook_url: &str, message: Option, ) -> Result<(), ApiError> { - let metadata = get_webhook_metadata(project_id, pool, redis).await?; + let metadata = get_webhook_metadata(project_id, pool, redis) + .await + .wrap_api_err("fetching webhook metadata")?; if let Some(project) = metadata { let mut fields = vec![]; @@ -503,11 +512,10 @@ pub async fn send_discord_webhook( }) .send() .await - .map_err(|_| { - ApiError::Discord( - "Error while sending projects webhook".to_string(), - ) - })?; + .map_err(|err| eyre::eyre!(err)) + .wrap_failed_dependency_err( + "error while sending projects webhook".to_string(), + )?; } Ok(())