@@ -4,17 +4,18 @@ use axum::{
44 extract:: { Form , Path , State } ,
55 http:: { header, StatusCode } ,
66 response:: { Html , IntoResponse , Response } ,
7- routing:: get,
8- Router ,
7+ routing:: { get, post } ,
8+ Json , Router ,
99} ;
10- use serde:: Deserialize ;
10+ use serde:: { Deserialize , Serialize } ;
1111use shadowmap:: { run, Args } ;
1212use tracing:: { error, info} ;
1313
1414mod web;
1515
1616use web:: {
17- render_index_page, render_job_row, render_job_rows, AppState , JobConfig , JobId , JobStatus ,
17+ render_index_page, render_job_row, render_job_rows, render_landing_page, AppState , JobConfig ,
18+ JobId , JobStatus , LandingPageContext , PricingPlan ,
1819} ;
1920
2021#[ derive( Debug , Deserialize ) ]
@@ -46,10 +47,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
4647
4748 let state = AppState :: new ( ) ;
4849 let app = Router :: new ( )
49- . route ( "/" , get ( index) )
50+ . route ( "/" , get ( landing) )
51+ . route ( "/app" , get ( index) )
5052 . route ( "/jobs" , get ( list_jobs) . post ( create_job) )
5153 . route ( "/jobs/:id" , get ( job_row) )
5254 . route ( "/jobs/:id/report" , get ( job_report) )
55+ . route ( "/create-checkout-session" , post ( create_checkout_session) )
5356 . with_state ( state) ;
5457
5558 let addr: SocketAddr = "0.0.0.0:8080" . parse ( ) ?;
@@ -68,6 +71,11 @@ async fn shutdown_signal() {
6871 info ! ( "shutdown signal received" ) ;
6972}
7073
74+ async fn landing ( ) -> Html < String > {
75+ let context = build_landing_page_context ( ) ;
76+ Html ( render_landing_page ( & context) )
77+ }
78+
7179async fn index ( State ( state) : State < AppState > ) -> Html < String > {
7280 let jobs = state. list_jobs ( ) . await ;
7381 Html ( render_index_page ( & jobs) )
@@ -155,3 +163,196 @@ async fn run_job(state: AppState, job_id: JobId, domain: String, config: JobConf
155163 }
156164 }
157165}
166+
167+ #[ derive( Debug , Deserialize ) ]
168+ struct CheckoutRequest {
169+ plan_id : String ,
170+ region : String ,
171+ email : Option < String > ,
172+ }
173+
174+ #[ derive( Debug , Serialize ) ]
175+ struct CheckoutResponse {
176+ session_id : String ,
177+ }
178+
179+ #[ derive( Debug , Deserialize ) ]
180+ struct StripeSession {
181+ id : String ,
182+ }
183+
184+ async fn create_checkout_session (
185+ Json ( payload) : Json < CheckoutRequest > ,
186+ ) -> Result < Json < CheckoutResponse > , ( StatusCode , String ) > {
187+ let plan = payload. plan_id . trim ( ) . to_lowercase ( ) ;
188+ let region = payload. region . trim ( ) . to_lowercase ( ) ;
189+ let Some ( env_key) = stripe_price_env_key ( & plan, & region) else {
190+ return Err ( (
191+ StatusCode :: BAD_REQUEST ,
192+ "Unknown plan or region selected" . to_string ( ) ,
193+ ) ) ;
194+ } ;
195+
196+ let price_id = std:: env:: var ( env_key) . map_err ( |_| {
197+ (
198+ StatusCode :: BAD_REQUEST ,
199+ "Checkout is not available for the selected plan in this region yet." . to_string ( ) ,
200+ )
201+ } ) ?;
202+
203+ let secret_key = std:: env:: var ( "STRIPE_SECRET_KEY" ) . map_err ( |_| {
204+ (
205+ StatusCode :: SERVICE_UNAVAILABLE ,
206+ "Stripe payments are not configured." . to_string ( ) ,
207+ )
208+ } ) ?;
209+
210+ let success_url = std:: env:: var ( "STRIPE_SUCCESS_URL" )
211+ . unwrap_or_else ( |_| "https://shadowmap.io/app?checkout=success" . to_string ( ) ) ;
212+ let cancel_url = std:: env:: var ( "STRIPE_CANCEL_URL" )
213+ . unwrap_or_else ( |_| "https://shadowmap.io/pricing" . to_string ( ) ) ;
214+
215+ let mut form_body = vec ! [
216+ ( "mode" . to_string( ) , "subscription" . to_string( ) ) ,
217+ ( "line_items[0][price]" . to_string( ) , price_id) ,
218+ ( "line_items[0][quantity]" . to_string( ) , "1" . to_string( ) ) ,
219+ ( "success_url" . to_string( ) , success_url) ,
220+ ( "cancel_url" . to_string( ) , cancel_url) ,
221+ ( "allow_promotion_codes" . to_string( ) , "true" . to_string( ) ) ,
222+ ] ;
223+
224+ if let Some ( email) = payload. email . and_then ( |value| {
225+ let trimmed = value. trim ( ) ;
226+ if trimmed. is_empty ( ) {
227+ None
228+ } else {
229+ Some ( trimmed. to_string ( ) )
230+ }
231+ } ) {
232+ form_body. push ( ( "customer_email" . to_string ( ) , email) ) ;
233+ }
234+
235+ let client = reqwest:: Client :: new ( ) ;
236+ let response = client
237+ . post ( "https://api.stripe.com/v1/checkout/sessions" )
238+ . bearer_auth ( secret_key)
239+ . form ( & form_body)
240+ . send ( )
241+ . await
242+ . map_err ( |err| {
243+ error ! ( ?err, "failed to talk to stripe" ) ;
244+ (
245+ StatusCode :: BAD_GATEWAY ,
246+ "Unable to reach Stripe right now. Please try again." . to_string ( ) ,
247+ )
248+ } ) ?;
249+
250+ let status = response. status ( ) ;
251+ let body = response. text ( ) . await . map_err ( |err| {
252+ error ! ( ?err, "failed to read stripe response" ) ;
253+ (
254+ StatusCode :: BAD_GATEWAY ,
255+ "Received an unexpected response from Stripe." . to_string ( ) ,
256+ )
257+ } ) ?;
258+
259+ if !status. is_success ( ) {
260+ error ! ( ?status, body = %body, "stripe returned an error" ) ;
261+ return Err ( (
262+ StatusCode :: BAD_GATEWAY ,
263+ "Stripe rejected the checkout session. Contact support if this persists." . to_string ( ) ,
264+ ) ) ;
265+ }
266+
267+ let session: StripeSession = serde_json:: from_str ( & body) . map_err ( |err| {
268+ error ! ( ?err, body = %body, "failed to parse stripe session" ) ;
269+ (
270+ StatusCode :: BAD_GATEWAY ,
271+ "Unexpected response from payment processor." . to_string ( ) ,
272+ )
273+ } ) ?;
274+
275+ Ok ( Json ( CheckoutResponse {
276+ session_id : session. id ,
277+ } ) )
278+ }
279+
280+ fn build_landing_page_context ( ) -> LandingPageContext {
281+ let publishable_key = std:: env:: var ( "STRIPE_PUBLISHABLE_KEY" ) . ok ( ) ;
282+ let plans = vec ! [
283+ PricingPlan {
284+ id: "starter" ,
285+ name: "Starter" ,
286+ summary: "Launch automation-grade recon for boutique agencies and red teams." ,
287+ ideal_for: "Solo operators" ,
288+ highlight: false ,
289+ usd_cents: 7900 ,
290+ eur_cents: 7500 ,
291+ features: & [
292+ "Unlimited on-demand reconnaissance jobs" ,
293+ "Live subdomain, DNS, and takeover detection" ,
294+ "Automated PDF & JSON reporting exports" ,
295+ "Email support with 1-business-day SLA" ,
296+ ] ,
297+ checkout_ready_us: stripe_price_configured( "starter" , "us" ) ,
298+ checkout_ready_eu: stripe_price_configured( "starter" , "eu" ) ,
299+ } ,
300+ PricingPlan {
301+ id: "growth" ,
302+ name: "Growth" ,
303+ summary: "Scale your practice with team workspaces, automations, and real-time monitoring." ,
304+ ideal_for: "Agencies & MSSPs" ,
305+ highlight: true ,
306+ usd_cents: 15900 ,
307+ eur_cents: 14900 ,
308+ features: & [
309+ "Everything in Starter plus scheduled monitoring" ,
310+ "Team workspaces with role-based access control" ,
311+ "Slack and webhook alerting for high-risk findings" ,
312+ "Dedicated success engineer and quarterly playbooks" ,
313+ ] ,
314+ checkout_ready_us: stripe_price_configured( "growth" , "us" ) ,
315+ checkout_ready_eu: stripe_price_configured( "growth" , "eu" ) ,
316+ } ,
317+ PricingPlan {
318+ id: "enterprise" ,
319+ name: "Enterprise" ,
320+ summary: "For global security organizations that need private deployments and custom workflows." ,
321+ ideal_for: "Global teams" ,
322+ highlight: false ,
323+ usd_cents: 34900 ,
324+ eur_cents: 32900 ,
325+ features: & [
326+ "Private cloud or on-premise deployment options" ,
327+ "Custom modules and API surface for internal tooling" ,
328+ "Advanced compliance reporting & SOC2 documentation" ,
329+ "24/7 incident response with named TAM" ,
330+ ] ,
331+ checkout_ready_us: stripe_price_configured( "enterprise" , "us" ) ,
332+ checkout_ready_eu: stripe_price_configured( "enterprise" , "eu" ) ,
333+ } ,
334+ ] ;
335+
336+ LandingPageContext {
337+ publishable_key,
338+ plans,
339+ }
340+ }
341+
342+ fn stripe_price_configured ( plan : & str , region : & str ) -> bool {
343+ stripe_price_env_key ( plan, region)
344+ . and_then ( |key| std:: env:: var ( key) . ok ( ) )
345+ . is_some ( )
346+ }
347+
348+ fn stripe_price_env_key ( plan : & str , region : & str ) -> Option < & ' static str > {
349+ match ( plan, region) {
350+ ( "starter" , "us" ) => Some ( "STRIPE_PRICE_STARTER_USD" ) ,
351+ ( "starter" , "eu" ) => Some ( "STRIPE_PRICE_STARTER_EUR" ) ,
352+ ( "growth" , "us" ) => Some ( "STRIPE_PRICE_GROWTH_USD" ) ,
353+ ( "growth" , "eu" ) => Some ( "STRIPE_PRICE_GROWTH_EUR" ) ,
354+ ( "enterprise" , "us" ) => Some ( "STRIPE_PRICE_ENTERPRISE_USD" ) ,
355+ ( "enterprise" , "eu" ) => Some ( "STRIPE_PRICE_ENTERPRISE_EUR" ) ,
356+ _ => None ,
357+ }
358+ }
0 commit comments