From da0fed3e218104eee37979e8f84be42a63cd5cde Mon Sep 17 00:00:00 2001 From: fetch Date: Mon, 4 Aug 2025 20:31:49 -0400 Subject: [PATCH] Add `public` column to products prices, only expose public prices --- .../20250805001654_product-prices-public.sql | 6 ++ .../src/database/models/product_item.rs | 59 +++++++++++-------- .../src/database/models/users_redeemals.rs | 4 +- apps/labrinth/src/routes/internal/billing.rs | 10 ++-- apps/labrinth/src/routes/internal/medal.rs | 56 +++++++++++++++--- apps/labrinth/src/routes/mod.rs | 4 ++ 6 files changed, 98 insertions(+), 41 deletions(-) create mode 100644 apps/labrinth/migrations/20250805001654_product-prices-public.sql diff --git a/apps/labrinth/migrations/20250805001654_product-prices-public.sql b/apps/labrinth/migrations/20250805001654_product-prices-public.sql new file mode 100644 index 000000000..c22eea1c5 --- /dev/null +++ b/apps/labrinth/migrations/20250805001654_product-prices-public.sql @@ -0,0 +1,6 @@ +-- Add migration script here + +ALTER TABLE + products_prices +ADD COLUMN + public BOOLEAN NOT NULL DEFAULT true; diff --git a/apps/labrinth/src/database/models/product_item.rs b/apps/labrinth/src/database/models/product_item.rs index 3879fe41a..dc7187992 100644 --- a/apps/labrinth/src/database/models/product_item.rs +++ b/apps/labrinth/src/database/models/product_item.rs @@ -100,7 +100,8 @@ pub struct QueryProductWithPrices { } impl QueryProductWithPrices { - pub async fn list<'a, E>( + /// Lists products with at least one public price. + pub async fn list_purchaseable<'a, E>( exec: E, redis: &RedisPool, ) -> Result, DatabaseError> @@ -118,30 +119,32 @@ impl QueryProductWithPrices { } let all_products = product_item::DBProduct::get_all(exec).await?; - let prices = product_item::DBProductPrice::get_all_products_prices( - &all_products.iter().map(|x| x.id).collect::>(), - exec, - ) - .await?; + let prices = + product_item::DBProductPrice::get_all_public_products_prices( + &all_products.iter().map(|x| x.id).collect::>(), + exec, + ) + .await?; let products = all_products .into_iter() - .map(|x| QueryProductWithPrices { - id: x.id, - metadata: x.metadata, - prices: prices - .remove(&x.id) - .map(|x| x.1) - .unwrap_or_default() - .into_iter() - .map(|x| DBProductPrice { - id: x.id, - product_id: x.product_id, - prices: x.prices, - currency_code: x.currency_code, - }) - .collect(), - unitary: x.unitary, + .filter_map(|x| { + Some(QueryProductWithPrices { + id: x.id, + metadata: x.metadata, + prices: prices + .remove(&x.id) + .map(|x| x.1)? + .into_iter() + .map(|x| DBProductPrice { + id: x.id, + product_id: x.product_id, + prices: x.prices, + currency_code: x.currency_code, + }) + .collect(), + unitary: x.unitary, + }) }) .collect::>(); @@ -222,16 +225,19 @@ impl DBProductPrice { .collect::, serde_json::Error>>()?) } - pub async fn get_all_product_prices( + pub async fn get_all_public_product_prices( product_id: DBProductId, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result, DatabaseError> { - let res = Self::get_all_products_prices(&[product_id], exec).await?; + let res = + Self::get_all_public_products_prices(&[product_id], exec).await?; Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default()) } - pub async fn get_all_products_prices( + /// Gets all public prices for the given products. If a product has no public price, + /// it won't be included in the resulting map. + pub async fn get_all_public_products_prices( product_ids: &[DBProductId], exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result>, DatabaseError> { @@ -240,7 +246,8 @@ impl DBProductPrice { use futures_util::TryStreamExt; let prices = select_prices_with_predicate!( - "WHERE product_id = ANY($1::bigint[])", + "WHERE product_id = ANY($1::bigint[]) + AND public = true", ids_ref ) .fetch(exec) diff --git a/apps/labrinth/src/database/models/users_redeemals.rs b/apps/labrinth/src/database/models/users_redeemals.rs index 2e18c4bf6..65b591eba 100644 --- a/apps/labrinth/src/database/models/users_redeemals.rs +++ b/apps/labrinth/src/database/models/users_redeemals.rs @@ -145,7 +145,7 @@ impl RedeemalLookupFields { /// /// If the returned value is `Ok(Some(fields))`, but `redeemal_status` is `None`, /// the user exists and has not redeemed the offer. - pub async fn redeemal_status_by_user_username_and_offer<'a, E>( + pub async fn redeemal_status_by_username_and_offer<'a, E>( exec: E, user_username: &str, offer: Offer, @@ -183,7 +183,7 @@ impl RedeemalLookupFields { redeemal_status: row .status .as_deref() - .map(|s| Status::from_str_or_default(s)), + .map(Status::from_str_or_default), })) } } diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 49e50eac7..a2a88ed53 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -59,8 +59,10 @@ pub async fn products( pool: web::Data, redis: web::Data, ) -> Result { - let products = - product_item::QueryProductWithPrices::list(&**pool, &redis).await?; + let products = product_item::QueryProductWithPrices::list_purchaseable( + &**pool, &redis, + ) + .await?; let products = products .into_iter() @@ -408,7 +410,7 @@ pub async fn edit_subscription( let intent = if let Some(product_id) = &edit_subscription.product { let product_price = - product_item::DBProductPrice::get_all_product_prices( + product_item::DBProductPrice::get_all_public_product_prices( (*product_id).into(), &mut *transaction, ) @@ -1187,7 +1189,7 @@ pub async fn initiate_payment( })?; let mut product_prices = - product_item::DBProductPrice::get_all_product_prices( + product_item::DBProductPrice::get_all_public_product_prices( product.id, &**pool, ) .await?; diff --git a/apps/labrinth/src/routes/internal/medal.rs b/apps/labrinth/src/routes/internal/medal.rs index 2f5e55b14..a4e2a113a 100644 --- a/apps/labrinth/src/routes/internal/medal.rs +++ b/apps/labrinth/src/routes/internal/medal.rs @@ -1,9 +1,12 @@ -use actix_web::{HttpRequest, HttpResponse, post, web}; +use actix_web::{HttpResponse, post, web}; use ariadne::ids::UserId; +use chrono::Utc; use serde::{Deserialize, Serialize}; use sqlx::PgPool; -use crate::database::models::users_redeemals::{Offer, RedeemalLookupFields}; +use crate::database::models::users_redeemals::{ + Offer, RedeemalLookupFields, Status, UserRedeemal, +}; use crate::routes::ApiError; use crate::util::guards::medal_key_guard; @@ -12,18 +15,17 @@ pub fn config(cfg: &mut web::ServiceConfig) { } #[derive(Deserialize)] -struct MedalVerifyQuery { +struct MedalQuery { username: String, } #[post("verify", guard = "medal_key_guard")] pub async fn verify( - _req: HttpRequest, pool: web::Data, - web::Query(MedalVerifyQuery { username }): web::Query, + web::Query(MedalQuery { username }): web::Query, ) -> Result { let maybe_fields = - RedeemalLookupFields::redeemal_status_by_user_username_and_offer( + RedeemalLookupFields::redeemal_status_by_username_and_offer( &**pool, &username, Offer::Medal, @@ -47,8 +49,44 @@ pub async fn verify( #[post("redeem", guard = "medal_key_guard")] pub async fn redeem( - _req: HttpRequest, - _pool: web::Data, + pool: web::Data, + web::Query(MedalQuery { username }): web::Query, ) -> Result { - Ok(HttpResponse::NotImplemented().finish()) + // Check the offer hasn't been redeemed yet, then insert into the table. + + let mut txn = pool.begin().await?; + + let maybe_fields = + RedeemalLookupFields::redeemal_status_by_username_and_offer( + &mut *txn, + &username, + Offer::Medal, + ) + .await?; + + let _redeemal = match maybe_fields { + None => return Err(ApiError::NotFound), + Some(fields) => { + if fields.redeemal_status.is_some() { + return Err(ApiError::Conflict( + "User already redeemed this offer".to_string(), + )); + } + + let mut redeemal = UserRedeemal { + id: 0, + user_id: fields.user_id, + offer: Offer::Medal, + redeemed: Utc::now(), + status: Status::Pending, + }; + + redeemal.insert(&mut *txn).await?; + redeemal + } + }; + + txn.commit().await?; + + Ok(HttpResponse::Ok().finish()) } diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index caa143f25..c637c79a0 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -137,6 +137,8 @@ pub enum ApiError { Io(#[from] std::io::Error), #[error("Resource not found")] NotFound, + #[error("Conflict: {0}")] + Conflict(String), #[error( "You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining." )] @@ -172,6 +174,7 @@ impl ApiError { ApiError::Clickhouse(..) => "clickhouse_error", ApiError::Reroute(..) => "reroute_error", ApiError::NotFound => "not_found", + ApiError::Conflict(..) => "conflict", ApiError::Zip(..) => "zip_error", ApiError::Io(..) => "io_error", ApiError::RateLimitError(..) => "ratelimit_error", @@ -208,6 +211,7 @@ impl actix_web::ResponseError for ApiError { ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR, ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR, ApiError::NotFound => StatusCode::NOT_FOUND, + ApiError::Conflict(..) => StatusCode::CONFLICT, ApiError::Zip(..) => StatusCode::BAD_REQUEST, ApiError::Io(..) => StatusCode::BAD_REQUEST, ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,