diff --git a/Cargo.lock b/Cargo.lock index 612de89f7d..8f809dde07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1028,6 +1028,23 @@ dependencies = [ "smol_str 0.3.2", ] +[[package]] +name = "async-stripe-webhook" +version = "1.0.0-alpha.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75f8382af92463fa8e0b86e825d7d08eecbcd8715c197f1a66173fcc18e35774" +dependencies = [ + "async-stripe-shared", + "async-stripe-types", + "chrono", + "hex", + "hmac 0.12.1", + "miniserde", + "serde", + "sha2 0.10.8", + "thiserror 1.0.63", +] + [[package]] name = "async-task" version = "4.7.1" @@ -1307,14 +1324,18 @@ dependencies = [ "anyhow", "async-stripe 1.0.0-alpha.2", "async-stripe-core", + "async-stripe-webhook", "chrono", "errors", + "http_server_common", "log", "mockall", + "mysql_queries", "once_cell", "serde", "serde_derive", "serde_json", + "sqlx", "tokio", "workspace-build-acceleration", ] diff --git a/crates/service/plugins/billing_artcraft/Cargo.toml b/crates/service/plugins/billing_artcraft/Cargo.toml index 048ee07653..b8e33c7969 100644 --- a/crates/service/plugins/billing_artcraft/Cargo.toml +++ b/crates/service/plugins/billing_artcraft/Cargo.toml @@ -1,7 +1,7 @@ [package] publish = false name = "billing_artcraft_component" -edition = "2018" +edition = "2024" version = "0.0.1" authors = [ "Brandon Thomas ", @@ -22,9 +22,8 @@ workspace-build-acceleration.workspace = true # Internal errors.workspace = true -#mysql_queries.workspace = true -#http_server_common = { path = "../../../lib/deprecated/http_server_common" } -#mysql_queries = { path = "../../../schema/database/mysql_queries" } +http_server_common = { path = "../../../lib/deprecated/http_server_common" } +mysql_queries = { path = "../../../schema/database/mysql_queries" } #reusable_types = { path = "../../../schema/public/reusable_types" } #url_config = { path = "../../../lib/url_config" } #user_traits_component = { path = "../user_traits_component" } @@ -39,11 +38,13 @@ once_cell.workspace = true serde.workspace = true serde_derive.workspace = true serde_json.workspace = true +sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio", "tls-rustls", "chrono" ] } # Money #async-stripe = { version = "1.0.0-alpha.2", features = ["runtime-tokio-hyper"] } async-stripe = { version = "1.0.0-alpha.2", features = [] } async-stripe-core = { version = "1.0.0-alpha.2", features = ["customer"] } +async-stripe-webhook = { version = "1.0.0-alpha.2", features = [] } #sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio", "tls-rustls", "chrono" ] } #utoipa = { version = "4", features = ["chrono","actix_extras"] } diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs new file mode 100644 index 0000000000..2d3f703125 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/mod.rs @@ -0,0 +1 @@ +pub mod webhook; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/mod.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/mod.rs new file mode 100644 index 0000000000..2c46362786 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/mod.rs @@ -0,0 +1,2 @@ +pub mod stripe_artcraft_webhook_handler; +pub mod stripe_artcraft_webhook_summary; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_handler.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_handler.rs new file mode 100644 index 0000000000..9663cc3014 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_handler.rs @@ -0,0 +1,363 @@ +use actix_web::web::{Bytes, Data, Json}; +use actix_web::{web, HttpRequest, HttpResponse}; + +use crate::endpoints::webhook::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary; +use crate::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient; +use crate::utils::common_web_error::CommonWebError; +use crate::utils::verify_stripe_webhook_ip_address::verify_stripe_webhook_ip_address; +use chrono::NaiveDateTime; +use http_server_common::request::get_request_header_optional::get_request_header_optional; +use log::{error, info, warn}; +use mysql_queries::queries::billing::stripe::get_stripe_webhook_event_log_by_id::{get_stripe_webhook_event_log_by_id, get_stripe_webhook_event_log_by_id_with_connection}; +use mysql_queries::queries::billing::stripe::insert_stripe_webhook_event_log::InsertStripeWebhookEventLog; +use serde_derive::Serialize; +use sqlx::pool::PoolConnection; +use sqlx::{MySql, MySqlConnection, MySqlPool}; +use std::sync::Arc; +use stripe_webhook::{Event, Webhook}; + +#[derive(Serialize)] +pub struct StripeArtcraftWebhookSuccessResponse { + pub success: bool, +} + +// /// Stripe (Artcraft) Webhook Handler +// #[utoipa::path( +// post, +// tag = "Stripe (Artcraft)", +// path = "/v1/stripe_artcraft/webhook", +// responses( +// (status = 200, description = "Success", body = StripeArtcraftWebhookSuccessResponse), +// ), +// params() +// )] +pub async fn stripe_artcraft_webhook_handler( + http_request: HttpRequest, + //server_state: Data>, + stripe_config: Data, + request_body_bytes: Bytes, + mysql_pool: Data, +) -> Result, CommonWebError> +{ + verify_stripe_webhook_ip_address(&http_request) + .map_err(|e| { + let reason = format!("Improper client IP address. Error: {:?}", e); + error!("{}", &reason); + CommonWebError::BadInputWithSimpleMessage("bad request".to_string()) + })?; + + let secret_signing_key = &stripe_config.secret_webhook_signing_key; + + let stripe_signature = get_request_header_optional(&http_request, "Stripe-Signature") + .unwrap_or_default(); + + // NB: Treat the request payload as unstructured and defer to Stripe libraries. + let webhook_payload = String::from_utf8(request_body_bytes.to_vec()) + .map_err(|err| { + let reason = format!("Could not decode request body to UTF-8: {:?}", err); + error!("{}", &reason); + CommonWebError::BadInputWithSimpleMessage(reason) + })?; + + let webhook_payload = Webhook::construct_event(&webhook_payload, &stripe_signature, secret_signing_key) + .map_err(|e| { + let reason = format!("Could not construct Stripe webhook event: {:?}", e); + error!("{}", &reason); + println!("{:?}", webhook_payload); + CommonWebError::BadInputWithSimpleMessage(reason) + })?; + + // Events can be re-sent, so we need to make handling them idempotent. + let stripe_event_id = webhook_payload.id.to_string(); + + let stripe_event_created_at = NaiveDateTime::from_timestamp(webhook_payload.created, 0); + + let stripe_event_type = webhook_payload.type_.as_str().to_string(); + + //let stripe_event_type = serde_json::to_string(&webhook_payload.type_) + // .map(|s| s.replace("\"", "")) + // .map_err(|err| { + // let reason = format!("Could not serialize webhook type: {:?}", err); + // error!("{}", &reason); + // CommonWebError::BadInputWithSimpleMessage(reason) + // })?; + + // NB: Whether this was test data or live data + let stripe_is_production = webhook_payload.livemode; + + info!("Stripe webhook event type: {} ({:?}); is production: {}, created at: {}, pending events to be handled: {}", + &stripe_event_type, + &webhook_payload.type_, + stripe_is_production, + &stripe_event_created_at, + webhook_payload.pending_webhooks); + + let mut mysql_connection = mysql_pool + .acquire() + .await?; + + let maybe_previously_played_event = get_stripe_webhook_event_log_by_id_with_connection(&stripe_event_id, &mut mysql_connection) + .await + .map_err(|err| { + let reason = format!("Could not query previous event by ID ({}): {:?}", &stripe_event_id, err); + error!("{}", &reason); + CommonWebError::ServerError + })?; + + if let Some(event) = maybe_previously_played_event { + // The event is being replayed by Stripe, and we've already handled it. + // We'll ignore it so that we remain idempotent. + if event.should_ignore_retry { + warn!("Stripe is replying event with ID={}. Ignoring it since we have previously handled it.", + &stripe_event_id); + + return Ok(Json(StripeArtcraftWebhookSuccessResponse { + success: true, + })); + } + } + + let webhook_summary = handle_webhook_payload(&mut mysql_connection, webhook_payload, &stripe_event_type).await?; + + let query = InsertStripeWebhookEventLog { + stripe_event_id, + stripe_event_type, + maybe_stripe_event_entity_id: webhook_summary.maybe_event_entity_id, + maybe_stripe_customer_id: webhook_summary.maybe_stripe_customer_id, + stripe_event_created_at, + stripe_is_production, + maybe_user_token: webhook_summary.maybe_user_token, + action_was_taken: webhook_summary.action_was_taken, + should_ignore_retry: webhook_summary.should_ignore_retry, + }; + + query.insert(&mysql_pool) + .await + .map_err(|err| { + let reason = format!("Could not insert Stripe webhook event log: {:?}", err); + error!("{}", &reason); + CommonWebError::ServerError + })?; + + Ok(Json(StripeArtcraftWebhookSuccessResponse { + success: true, + })) +} + +async fn handle_webhook_payload( + mysql_connection: &mut PoolConnection, + webhook_payload: Event, + stripe_event_type: &String +) -> Result { + let mut unhandled_event_type = false; + + let mut webhook_summary = StripeArtcraftWebhookSummary { + maybe_user_token: None, + maybe_event_entity_id: None, + maybe_stripe_customer_id: None, + action_was_taken: false, + should_ignore_retry: false, + }; + + // NB: + // - "Webhook endpoints might occasionally receive the same event more than once." + // - "Stripe does not guarantee delivery of events in the order in which they are generated." + /* + match webhook_payload.type_ { + + // =============== CHECKOUT SESSIONS =============== + + EventType::CheckoutSessionCompleted => { + if let EventObject::CheckoutSession(checkout_session) = webhook_payload.data.object { + webhook_summary = checkout_session_completed_handler(checkout_session)?; + } + } + + // EventType::CheckoutSessionExpired => {} + // EventType::CheckoutSessionAsyncPaymentFailed => {} + // EventType::CheckoutSessionAsyncPaymentSucceeded => {} + + // =============== CUSTOMERS =============== + + EventType::CustomerCreated => { + if let EventObject::Customer(customer) = webhook_payload.data.object { + webhook_summary = customer_created_handler(&customer)?; + } + } + EventType::CustomerUpdated => { + if let EventObject::Customer(customer) = webhook_payload.data.object { + webhook_summary = customer_updated_handler(&customer)?; + } + } + EventType::CustomerDeleted => { + if let EventObject::Customer(customer) = webhook_payload.data.object { + webhook_summary = customer_deleted_handler(&customer)?; + } + } + + // =============== CUSTOMER SUBSCRIPTIONS =============== + + EventType::CustomerSubscriptionCreated => { + if let EventObject::Subscription(subscription) = webhook_payload.data.object { + webhook_summary = customer_subscription_created_handler( + &subscription, + internal_subscription_product_lookup.get_ref(), + &mysql_pool).await?; + } + } + EventType::CustomerSubscriptionUpdated => { + if let EventObject::Subscription(subscription) = webhook_payload.data.object { + webhook_summary = customer_subscription_updated_handler( + &subscription, + internal_subscription_product_lookup.get_ref(), + &mysql_pool).await?; + } + } + EventType::CustomerSubscriptionDeleted => { + if let EventObject::Subscription(subscription) = webhook_payload.data.object { + webhook_summary = customer_subscription_deleted_handler( + &subscription, + internal_subscription_product_lookup.get_ref(), + &mysql_pool).await?; + } + } + + // EventType::CustomerSubscriptionPendingUpdateApplied => {} + // EventType::CustomerSubscriptionPendingUpdateExpired => {} + // EventType::CustomerSubscriptionTrialWillEnd => {} + + // =============== INVOICES =============== + + EventType::InvoiceCreated => { + // TODO: We need to respond to this so we don't hold payments up by 72 hours! + // See: https://stripe.com/docs/billing/subscriptions/webhooks + } + EventType::InvoiceUpdated => { + if let EventObject::Invoice(invoice) = webhook_payload.data.object { + webhook_summary = invoice_updated_handler(&invoice)?; + } + } + EventType::InvoicePaid => { + if let EventObject::Invoice(invoice) = webhook_payload.data.object { + webhook_summary = invoice_paid_handler(&invoice)?; + } + } + EventType::InvoicePaymentSucceeded => { + if let EventObject::Invoice(invoice) = webhook_payload.data.object { + webhook_summary = invoice_payment_succeeded_handler(&invoice)?; + } + } + EventType::InvoicePaymentFailed => { + if let EventObject::Invoice(invoice) = webhook_payload.data.object { + webhook_summary = invoice_payment_failed_handler(&invoice)?; + } + } + + // EventType::InvoiceDeleted => {} + // EventType::InvoiceFinalizationFailed => {} + // EventType::InvoiceFinalized => {} + // EventType::InvoiceItemCreated => {} + // EventType::InvoiceItemDeleted => {} + // EventType::InvoiceItemUpdated => {} + // EventType::InvoiceMarkedUncollectible => {} + // EventType::InvoicePaymentActionRequired => {} + // EventType::InvoiceSent => {} + // EventType::InvoiceUpcoming => {} + // EventType::InvoiceUpdated => {} + // EventType::InvoiceVoided => {} + + // =============== PAYMENT INTENTS =============== + + EventType::PaymentIntentSucceeded => { + if let EventObject::PaymentIntent(payment_intent) = webhook_payload.data.object { + webhook_summary = payment_intent_succeeded_handler(&payment_intent)?; + } + } + + // EventType::PaymentIntentAmountCapturableUpdated => {} + // EventType::PaymentIntentCanceled => {} + // EventType::PaymentIntentCreated => {} + // EventType::PaymentIntentPartiallyFunded => {} + // EventType::PaymentIntentPaymentFailed => {} + // EventType::PaymentIntentProcessing => {} + // EventType::PaymentIntentRequiresAction => {} + // EventType::PaymentIntentRequiresCapture => {} + + // =============== CHARGES =============== + + EventType::ChargeSucceeded => { + if let EventObject::Charge(charge) = webhook_payload.data.object { + webhook_summary = charge_succeeded_handler(&charge)?; + } + } + + + // =============== Ignored =============== + + // Ignoring these types: + // Account* (6), + // ApplicationFee* (2), + // BalanceAvailable (1), + // BillingPortal* (2), + // CapabilityCreated (1), + // Charge* (13), + // [CheckoutSession* handled above], + // Coupon* (3), + // CreditNote* (3), + // CustomerDiscount* (3), + // CustomerSource* (4), + // [CustomerSubscription* handled above], + // CreditTax* (3), + // FileCreated (1), + // IdentityVerification* (6), + // [Invoice* handled above], + // IssuingAuthorization* (3), + // IssuingCard* (2), + // IssuingCardholder* (2), + // IssuingDispute* (2), + // IssuingTransaction* (2), + // MandateUpdated (1), + // Order* (6), + // [PaymentIntent* handled above], + // PaymentLink* (2), + // PaymentMethod* (4), + // Payout* (5), + // Person* (3), + // Plan* (3), + // Price* (3), + // Product* (3), + // PromotionCode* (2), + // Quote* (4), + // RadarEarlyFraudWarning* (2), + // Recipient* (3), + // ReportingReport* (3), + // Review* (2), + // SetupIntent* (5), -- N/A; collecting payments in the future we don't yet have information for (eg. crowdfunding, rental car, utility bill) + // SubscriptionSchedule* (7), -- N/A; backdate subscriptions or schedule one to start later (eg. for physical goods, like a newspaper) + // SigmaScheduledQueryRunCreated (1), + // Sku (3), + // Source* (7), + // TaxRate* (2), + // TerminalReader* (2), + // TestHelpersTestClock* (5), + // Topup* (5), + // Transfer* (5), + + _ => { + unhandled_event_type = true; + }, + } + */ + + // To play with the payload contents as JSON: + // let json = serde_json::ser::to_string(&event_payload).unwrap(); + // info!("event payload as json: {}", json); + + if unhandled_event_type { + warn!("Unhandled Stripe webhook event type: {} ({:?})", + &stripe_event_type, + &webhook_payload.type_); + } + + Ok(webhook_summary) +} diff --git a/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_summary.rs b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_summary.rs new file mode 100644 index 0000000000..e58e882cbe --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/endpoints/webhook/stripe_artcraft_webhook_summary.rs @@ -0,0 +1,21 @@ + +/// Reports common information from the webhook handlers +pub struct StripeArtcraftWebhookSummary { + /// If we recorded our own internal user ID as Stripe metadata, this passes it upstream. + pub maybe_user_token: Option, + + /// This is the core entity type associated with the webhook event. + /// We pass this upstream so we can record it and look it up later for debugging. + pub maybe_event_entity_id: Option, + + /// This is the stripe customer ID, if it was associated with the event. + pub maybe_stripe_customer_id: Option, + + /// Whether we took any sort of action in response to the webhook. + /// Not all event types have handlers yet, and even so, sometimes we may choose to do nothing. + pub action_was_taken: bool, + + /// Whether any retries should be dropped. + /// This helps us handle event with idempotency in the event Stripe replays them (and it can!) + pub should_ignore_retry: bool, +} diff --git a/crates/service/plugins/billing_artcraft/src/lib.rs b/crates/service/plugins/billing_artcraft/src/lib.rs index e69de29bb2..f33a053e90 100644 --- a/crates/service/plugins/billing_artcraft/src/lib.rs +++ b/crates/service/plugins/billing_artcraft/src/lib.rs @@ -0,0 +1,2 @@ +pub mod endpoints; +pub mod utils; \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/utils/artcraft_stripe_config.rs b/crates/service/plugins/billing_artcraft/src/utils/artcraft_stripe_config.rs new file mode 100644 index 0000000000..715ec0552f --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/utils/artcraft_stripe_config.rs @@ -0,0 +1,30 @@ +use stripe::Client; + +#[derive(Clone)] +pub struct ArtcraftStripeConfig { + pub secret_key: String, + pub secret_webhook_signing_key: String, + pub checkout_success_url: String, + pub checkout_cancel_url: String, +} + +#[derive(Clone)] +pub struct ArtcraftStripeConfigWithClient { + pub secret_key: String, + pub secret_webhook_signing_key: String, + pub checkout_success_url: String, + pub checkout_cancel_url: String, + pub client: Client, +} + +impl ArtcraftStripeConfig { + pub fn to_config_with_client(&self) -> ArtcraftStripeConfigWithClient { + ArtcraftStripeConfigWithClient { + secret_key: self.secret_key.clone(), + secret_webhook_signing_key: self.secret_webhook_signing_key.clone(), + checkout_success_url: self.checkout_success_url.clone(), + checkout_cancel_url: self.checkout_cancel_url.clone(), + client: Client::new(self.secret_key.clone()), + } + } +} diff --git a/crates/service/plugins/billing_artcraft/src/utils/common_web_error.rs b/crates/service/plugins/billing_artcraft/src/utils/common_web_error.rs new file mode 100644 index 0000000000..e6e08ec5c8 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/utils/common_web_error.rs @@ -0,0 +1,101 @@ +use actix_http::StatusCode; +use actix_web::{HttpResponse, HttpResponseBuilder, ResponseError}; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use log::error; +use serde_derive::Serialize; + +#[derive(Debug)] +pub enum CommonWebError { + // TODO: Bad input with error fields vector. + /// Bad input with a user-facing error message. + BadInputWithSimpleMessage(String), + /// Entity not found. + NotFound, + /// Not authorized to perform the action. + NotAuthorized, + /// Server error with no reason. + ServerError, + /// Payment required with no extra info + PaymentRequired, +} + +impl Error for CommonWebError {} + +impl Display for CommonWebError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +#[derive(Debug, Serialize)] +struct SerializeSimpleErrorWithoutMessage<'a> { + success: bool, + error_code: u16, + error_code_str: Option<&'a str>, +} + + +#[derive(Debug, Serialize)] +struct SerializeSimpleErrorWithMessage<'a> { + success: bool, + error_code: u16, + error_code_str: Option<&'a str>, + message: &'a str, +} + +impl ResponseError for CommonWebError { + fn status_code(&self) -> StatusCode { + match self { + CommonWebError::BadInputWithSimpleMessage(_) => StatusCode::BAD_REQUEST, + CommonWebError::NotFound => StatusCode::NOT_FOUND, + CommonWebError::NotAuthorized => StatusCode::UNAUTHORIZED, + CommonWebError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + CommonWebError::PaymentRequired => StatusCode::PAYMENT_REQUIRED, + } + } + + fn error_response(&self) -> HttpResponse { + match self { + CommonWebError::BadInputWithSimpleMessage(msg) => { + HttpResponse::BadRequest() + .json(SerializeSimpleErrorWithMessage { + success: false, + error_code: self.status_code().as_u16(), + error_code_str: self.status_code().canonical_reason(), + message: msg, + }) + }, + _ => { + HttpResponseBuilder::new(self.status_code()) + .json(SerializeSimpleErrorWithoutMessage { + success: false, + error_code: self.status_code().as_u16(), + error_code_str: self.status_code().canonical_reason(), + }) + } + } + } +} + +impl From for CommonWebError { + fn from(value: sqlx::Error) -> Self { + error!("SQLx error: {:?}", value); + CommonWebError::ServerError + } +} + +#[cfg(test)] +mod tests { + use super::*; + use actix_http::body::MessageBody; + + #[test] + fn test_bad_input_json() { + let error = CommonWebError::BadInputWithSimpleMessage("foo bar baz".to_string()); + let response = error.error_response(); + let bytes = response.into_body().try_into_bytes().unwrap(); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + assert_eq!(body, "{\"success\":false,\"error_code\":400,\"error_code_str\":\"Bad Request\",\"message\":\"foo bar baz\"}"); + } +} \ No newline at end of file diff --git a/crates/service/plugins/billing_artcraft/src/utils/mod.rs b/crates/service/plugins/billing_artcraft/src/utils/mod.rs new file mode 100644 index 0000000000..340eab5f2e --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod artcraft_stripe_config; +pub mod common_web_error; +pub mod verify_stripe_webhook_ip_address; diff --git a/crates/service/plugins/billing_artcraft/src/utils/verify_stripe_webhook_ip_address.rs b/crates/service/plugins/billing_artcraft/src/utils/verify_stripe_webhook_ip_address.rs new file mode 100644 index 0000000000..02d814f281 --- /dev/null +++ b/crates/service/plugins/billing_artcraft/src/utils/verify_stripe_webhook_ip_address.rs @@ -0,0 +1,48 @@ +use std::collections::HashSet; + +use actix_web::HttpRequest; +use anyhow::anyhow; +use errors::AnyhowResult; +use once_cell::sync::Lazy; + +use http_server_common::request::get_request_ip::get_request_ip; + +/// List of IP addresses that send webhook requests +/// From: https://stripe.com/docs/ips +static STRIPE_WEBHOOK_IP_ADDRESSES : Lazy> = Lazy::new(|| { + let mut ip_addresses = HashSet::new(); + + ip_addresses.extend([ + "3.18.12.63", + "3.130.192.231", + "13.235.14.237", + "13.235.122.149", + "18.211.135.69", + "35.154.171.200", + "52.15.183.38", + "54.88.130.119", + "54.88.130.237", + "54.187.174.169", + "54.187.205.235", + "54.187.216.72", + ].map(|s| s.to_string())); + + ip_addresses +}); + +/// Verify that the request comes from a Stripe webhook client IP +/// Recommendation from: https://stripe.com/docs/webhooks/best-practices +pub fn verify_stripe_webhook_ip_address(http_request: &HttpRequest) -> AnyhowResult<()> { + let ip_address = get_request_ip(http_request); + + if STRIPE_WEBHOOK_IP_ADDRESSES.contains(&ip_address) { + return Ok(()); + } + + // TODO: Only allow this in development + if ip_address == "127.0.0.1" { + return Ok(()); + } + + Err(anyhow!("Not a valid Stripe webhook IP address: {:?}", &ip_address)) +} \ No newline at end of file diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/stripe_artcraft/checkout/stripe_artcraft_create_subscription_checkout_session_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/stripe_artcraft/checkout/stripe_artcraft_create_subscription_checkout_session_handler.rs index 4902c53146..4efd2893f2 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/stripe_artcraft/checkout/stripe_artcraft_create_subscription_checkout_session_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/stripe_artcraft/checkout/stripe_artcraft_create_subscription_checkout_session_handler.rs @@ -180,15 +180,19 @@ pub async fn stripe_artcraft_create_checkout_session_handler( } } - CheckoutSession::create(&server_state.stripe_artcraft.client, params) - .await - .map_err(|e| { - error!("Stripe Error: {:?}", e); - CommonWebError::ServerError - })? + // TODO: THis is now broken + //CheckoutSession::create(&server_state.stripe_artcraft.client, params) + // .await + // .map_err(|e| { + // error!("Stripe Error: {:?}", e); + // CommonWebError::ServerError + // })? + () }; - let url = checkout_session.url.ok_or(CommonWebError::ServerError)?; + //let url = checkout_session.url.ok_or(CommonWebError::ServerError)?; + + let url = "todo".to_string(); // Best effort to delete Redis session cache internal_session_cache_purge.best_effort_purge_session_cache(&http_request); diff --git a/crates/service/web/storyteller_web/src/main.rs b/crates/service/web/storyteller_web/src/main.rs index 785952e549..a9e314bc69 100644 --- a/crates/service/web/storyteller_web/src/main.rs +++ b/crates/service/web/storyteller_web/src/main.rs @@ -45,6 +45,7 @@ use actix_helpers::middleware::disabled_endpoint_filter::disabled_endpoint_filte use actix_helpers::middleware::disabled_endpoint_filter::disabled_endpoints::disabled_endpoints::DisabledEndpoints; use actix_helpers::middleware::disabled_endpoint_filter::disabled_endpoints::exact_match_disabled_endpoints::ExactMatchDisabledEndpoints; use actix_helpers::middleware::disabled_endpoint_filter::disabled_endpoints::prefix_disabled_endpoints::PrefixDisabledEndpoints; +use billing_artcraft_component::utils::artcraft_stripe_config::ArtcraftStripeConfig; use billing_component::stripe::stripe_config::{FullUrlOrPath, StripeCheckoutConfigs, StripeConfig, StripeCustomerPortalConfigs, StripeSecrets}; use billing_component::stripe::traits::internal_product_to_stripe_lookup::InternalProductToStripeLookup; use billing_component::stripe::traits::internal_subscription_product_lookup::InternalSubscriptionProductLookup; @@ -413,17 +414,12 @@ async fn main() -> AnyhowResult<()> { client: stripe_client, }, stripe_artcraft: { - let secret_key = easyenv::get_env_string_required("STRIPE_ARTCRAFT_SECRET_KEY")?; - StripeArtcraftSettings { - secret_key: secret_key.clone(), + ArtcraftStripeConfig { + secret_key: easyenv::get_env_string_required("STRIPE_ARTCRAFT_SECRET_KEY")?, secret_webhook_signing_key: easyenv::get_env_string_required("STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY")?, checkout_success_url: easyenv::get_env_string_required("STRIPE_ARTCRAFT_CHECKOUT_SUCCESS_URL")?, checkout_cancel_url: easyenv::get_env_string_required("STRIPE_ARTCRAFT_CHECKOUT_CANCEL_URL")?, - client: { - let api_secret = secret_key.clone(); - async_stripe_artcraft::Client::new(api_secret) - }, - } + }.to_config_with_client() }, hostname: server_hostname, startup_time, @@ -623,6 +619,7 @@ pub async fn serve(server_state: ServerState) -> AnyhowResult<()> // NB: app_data being clone()'d below should all be safe (dependencies included) let app = App::new() + .app_data(web::Data::new(server_state_arc.stripe_artcraft.clone())) .app_data(web::Data::new(server_state_arc.firehose_publisher.clone())) .app_data(web::Data::new(server_state_arc.mysql_pool.clone())) .app_data(web::Data::new(server_state_arc.redis_pool.clone())) diff --git a/crates/service/web/storyteller_web/src/state/server_state.rs b/crates/service/web/storyteller_web/src/state/server_state.rs index 7f53eb5ccf..a1cf4c2028 100644 --- a/crates/service/web/storyteller_web/src/state/server_state.rs +++ b/crates/service/web/storyteller_web/src/state/server_state.rs @@ -5,6 +5,7 @@ use sqlx::MySqlPool; use actix_helpers::middleware::banned_cidr_filter::banned_cidr_set::BannedCidrSet; use actix_helpers::middleware::banned_ip_filter::ip_ban_list::ip_ban_list::IpBanList; +use billing_artcraft_component::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient; use billing_component::stripe::stripe_config::StripeConfig; use cloud_storage::bucket_client::BucketClient; use email_sender::smtp_email_sender::SmtpEmailSender; @@ -49,7 +50,7 @@ pub struct ServerState { pub server_info: ServerInfo, pub stripe: StripeSettings, - pub stripe_artcraft: StripeArtcraftSettings, + pub stripe_artcraft: ArtcraftStripeConfigWithClient, pub hostname: String,