Skip to content

Commit af76cb3

Browse files
authored
Merge pull request #53 from mdabir1203/codex/build-minimalist-landing-page-with-stripe-integration
[shadowmap] feat: add marketing landing page with Stripe checkout
2 parents 051ac5a + 1c8cd67 commit af76cb3

4 files changed

Lines changed: 861 additions & 6 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,32 @@ cargo fmt --all
4040
cargo clippy --workspace --all-targets -- -D warnings
4141
```
4242

43+
### Landing page & billing checkout
44+
45+
ShadowMap now ships with a minimalist marketing landing page, localized pricing, and optional Stripe Checkout integration.
46+
47+
1. Export your Stripe credentials and price IDs (test or live):
48+
```bash
49+
export STRIPE_PUBLISHABLE_KEY=pk_test_...
50+
export STRIPE_SECRET_KEY=sk_test_...
51+
export STRIPE_PRICE_STARTER_USD=price_123
52+
export STRIPE_PRICE_STARTER_EUR=price_456
53+
export STRIPE_PRICE_GROWTH_USD=price_789
54+
export STRIPE_PRICE_GROWTH_EUR=price_abc
55+
export STRIPE_PRICE_ENTERPRISE_USD=price_def
56+
export STRIPE_PRICE_ENTERPRISE_EUR=price_ghi
57+
# Optional overrides for post-checkout navigation
58+
export STRIPE_SUCCESS_URL=https://shadowmap.io/app?checkout=success
59+
export STRIPE_CANCEL_URL=https://shadowmap.io/pricing
60+
```
61+
62+
2. Launch the server:
63+
```bash
64+
cargo run --bin shadowmap-server
65+
```
66+
67+
3. Visit `http://localhost:8080/` for the public landing page and `http://localhost:8080/app` for the authenticated recon dashboard.
68+
4369
### Supply Chain Security
4470

4571
ShadowMap includes a lightweight workflow for generating a Software Bill of Materials (SBOM) and scanning it for known vulnerab

src/server.rs

Lines changed: 206 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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};
1111
use shadowmap::{run, Args};
1212
use tracing::{error, info};
1313

1414
mod web;
1515

1616
use 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+
7179
async 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+
}

src/web/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,7 @@ pub mod state;
22
pub mod views;
33

44
pub use state::{AppState, JobConfig, JobId, JobStatus};
5-
pub use views::{render_index_page, render_job_row, render_job_rows};
5+
pub use views::{
6+
render_index_page, render_job_row, render_job_rows, render_landing_page, LandingPageContext,
7+
PricingPlan,
8+
};

0 commit comments

Comments
 (0)