Skip to content

Commit cd7a2a7

Browse files
committed
refactor: dedupe Stripe calls, add CSRF, tidy flash/constants
1 parent 0d26341 commit cd7a2a7

8 files changed

Lines changed: 104 additions & 67 deletions

File tree

public/account/index.php

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
$user = require_login();
55

66
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
7-
// The delete form posts a "confirm" field; the profile form does not.
8-
if (isset($_POST['confirm'])) {
9-
if ($_POST['confirm'] !== 'DELETE') {
7+
csrf_check();
8+
$action = $_POST['action'] ?? '';
9+
10+
if ($action === 'delete') {
11+
if (($_POST['confirm'] ?? '') !== 'DELETE') {
1012
header('Location: /account');
1113
exit;
1214
}
@@ -17,12 +19,18 @@
1719
header('Location: /?deleted=1');
1820
exit;
1921
}
20-
$name = trim((string)($_POST['display_name'] ?? ''));
21-
db()->prepare('UPDATE users SET display_name = ? WHERE id = ?')->execute([$name, $user['id']]);
22-
header('Location: /account?saved=1');
22+
23+
if ($action === 'save') {
24+
$name = trim((string)($_POST['display_name'] ?? ''));
25+
db()->prepare('UPDATE users SET display_name = ? WHERE id = ?')->execute([$name, $user['id']]);
26+
header('Location: /account?saved=1');
27+
exit;
28+
}
29+
30+
header('Location: /account');
2331
exit;
2432
}
25-
$saved = isset($_GET['saved']);
33+
$saved = flash('saved');
2634
layout_header('Account');
2735
?>
2836
<p class="kicker">Settings</p>
@@ -32,6 +40,8 @@
3240
<?php endif; ?>
3341
<div class="card">
3442
<form method="post" action="/account">
43+
<?= csrf_field() ?>
44+
<input type="hidden" name="action" value="save">
3545
<label for="display_name">Display name</label>
3646
<input id="display_name" name="display_name" value="<?= htmlspecialchars((string)($user['display_name'] ?? '')) ?>">
3747
<label for="email">Email</label>
@@ -44,6 +54,8 @@
4454
<h2>Delete account</h2>
4555
<p>This permanently deletes your account and cancels any subscription. It cannot be undone.</p>
4656
<form method="post" action="/account" onsubmit="return confirm('Delete your account permanently?');">
57+
<?= csrf_field() ?>
58+
<input type="hidden" name="action" value="delete">
4759
<label for="confirm">Type DELETE to confirm</label>
4860
<input id="confirm" name="confirm" autocomplete="off">
4961
<button class="btn btn-danger" type="submit">Delete my account</button>

public/app/index.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
<?php elseif ($status === 'cancel'): ?>
1414
<p class="notice">Checkout canceled.</p>
1515
<?php endif; ?>
16-
<?php if (isset($_GET['upgrade'])): upgrade_prompt(); endif; ?>
16+
<?php if (flash('upgrade')): upgrade_prompt(); endif; ?>
1717

1818
<ul class="manifest">
1919
<?php if ($name !== ''): ?>

public/auth/login.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@
1717

1818
// Step 1: user asked for a link.
1919
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
20+
csrf_check();
2021
$email = filter_var(trim($_POST['email'] ?? ''), FILTER_VALIDATE_EMAIL);
2122
if (!$email) {
2223
$error = 'Enter a valid email address.';
2324
} else {
24-
$token = create_magic_link($email);
25-
$link = config()['base_url'] . '/auth/login?token=' . urlencode($token);
26-
send_mail($email, 'Your sign-in link', "Click to sign in:\n\n$link\n\nThis link expires in 15 minutes.");
25+
$token = create_magic_link($email);
26+
$link = config()['base_url'] . '/auth/login?token=' . urlencode($token);
27+
$minutes = (int)(MAGIC_LINK_TTL / 60);
28+
send_mail($email, 'Your sign-in link', "Click to sign in:\n\n$link\n\nThis link expires in $minutes minutes.");
2729
$sent = true;
2830
}
2931
}
@@ -39,6 +41,7 @@
3941
<?php else: ?>
4042
<div class="card">
4143
<form method="post" action="/auth/login">
44+
<?= csrf_field() ?>
4245
<label for="email">Email</label>
4346
<input id="email" type="email" name="email" required autofocus placeholder="you@example.com">
4447
<button class="btn" type="submit">Send magic link</button>

public/feedback.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
header('Location: /');
88
exit;
99
}
10+
csrf_check();
1011

1112
$message = trim((string)($_POST['message'] ?? ''));
1213
$email = filter_var(trim((string)($_POST['email'] ?? '')), FILTER_VALIDATE_EMAIL) ?: null;

public/index.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
// Email capture posts back to the landing page itself.
55
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
6+
csrf_check();
67
$email = filter_var(trim((string)($_POST['email'] ?? '')), FILTER_VALIDATE_EMAIL);
78
if ($email) {
89
// INSERT OR IGNORE: a duplicate email is a no-op, not an error.
@@ -13,7 +14,7 @@
1314
}
1415

1516
$user = current_user();
16-
$sub = isset($_GET['sub']);
17+
$sub = flash('sub');
1718
layout_header('Leanplate', 'A levelsio-style PHP micro-stack. SQLite, magic links, Stripe, one VPS, no framework.');
1819
?>
1920
<p class="kicker">PHP micro-stack template</p>
@@ -43,6 +44,7 @@
4344
<?php else: ?>
4445
<div class="card">
4546
<form method="post" action="/">
47+
<?= csrf_field() ?>
4648
<label for="email">Get updates</label>
4749
<input id="email" type="email" name="email" required placeholder="you@example.com">
4850
<button class="btn btn-secondary" type="submit">Subscribe</button>

src/app/auth.php

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,35 @@
33
// Passwordless: magic links + Google OAuth. Both resolve to find_or_create_user(email).
44
declare(strict_types=1);
55

6+
// How long a magic link stays valid. Also reused in the email copy.
7+
const MAGIC_LINK_TTL = 900; // 15 minutes
8+
9+
// --- csrf ---
10+
// One token per session. Put csrf_field() inside every state-changing <form>
11+
// and call csrf_check() at the top of its POST handler.
12+
13+
function csrf_token(): string
14+
{
15+
if (empty($_SESSION['csrf'])) {
16+
$_SESSION['csrf'] = bin2hex(random_bytes(32));
17+
}
18+
return $_SESSION['csrf'];
19+
}
20+
21+
function csrf_field(): string
22+
{
23+
return '<input type="hidden" name="csrf" value="' . htmlspecialchars(csrf_token()) . '">';
24+
}
25+
26+
function csrf_check(): void
27+
{
28+
$sent = $_POST['csrf'] ?? '';
29+
if (!is_string($sent) || !hash_equals(csrf_token(), $sent)) {
30+
http_response_code(400);
31+
exit('Bad request (invalid CSRF token). Reload the page and try again.');
32+
}
33+
}
34+
635
// --- users ---
736

837
function find_or_create_user(string $email): array
@@ -78,7 +107,7 @@ function create_magic_link(string $email): string
78107
$token = bin2hex(random_bytes(32));
79108
//Store only the hash so a DB leak cannot replay links.
80109
$hash = hash('sha256', $token);
81-
$expires = gmdate('Y-m-d H:i:s', time() + 900); //15 min expiry.
110+
$expires = gmdate('Y-m-d H:i:s', time() + MAGIC_LINK_TTL);
82111
db()->prepare('INSERT OR REPLACE INTO login_tokens(token_hash, email,expires_at) VALUES (?,?,?)')
83112
->execute([$hash, $email, $expires]);
84113
return $token;

src/app/stripe.php

Lines changed: 33 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,51 @@
33
// Stripe Checkout + webhook via raw curl. No SDK.
44
declare(strict_types=1);
55

6+
// Reject webhooks whose signed timestamp is older than this, to limit replay.
7+
const STRIPE_WEBHOOK_TOLERANCE = 300; // 5 minutes
8+
69
function stripe_enabled(): bool
710
{
811
$c = config();
912
return !empty($c['stripe_secret_key']) &&
1013
!empty($c['stripe_price_id']);
1114
}
1215

16+
// One call to the Stripe REST API. Secret key is the basic-auth user.
17+
// Returns the decoded response, or null on transport failure.
18+
// $method: 'GET' | 'POST' | 'DELETE'. POST sends $fields as form-encoded body.
19+
function stripe_api(string $method, string $path, array $fields = []): ?array
20+
{
21+
$opt = [
22+
CURLOPT_RETURNTRANSFER => true,
23+
CURLOPT_USERPWD => config()['stripe_secret_key'] . ':',
24+
CURLOPT_TIMEOUT => 20,
25+
];
26+
if ($method === 'POST') {
27+
$opt[CURLOPT_POST] = true;
28+
$opt[CURLOPT_POSTFIELDS] = http_build_query($fields);
29+
} elseif ($method === 'DELETE') {
30+
$opt[CURLOPT_CUSTOMREQUEST] = 'DELETE';
31+
}
32+
$ch = curl_init('https://api.stripe.com/v1/' . $path);
33+
curl_setopt_array($ch, $opt);
34+
$resp = curl_exec($ch);
35+
return $resp === false ? null : json_decode($resp, true);
36+
}
37+
1338
// Create a subscription Checkout Session, return its hosted URL.
1439
function stripe_create_checkout(array $user): ?string
1540
{
16-
$c = config();
17-
$fields = [
41+
$c = config();
42+
$data = stripe_api('POST', 'checkout/sessions', [
1843
'mode' => 'subscription',
1944
'success_url' => $c['base_url'] . '/app?checkout=success',
2045
'cancel_url' => $c['base_url'] . '/app?checkout=cancel',
2146
'customer_email' => $user['email'],
2247
'client_reference_id' => (string)$user['id'], // maps the webhook back to our user
2348
'line_items[0][price]' => $c['stripe_price_id'],
2449
'line_items[0][quantity]' => 1,
25-
];
26-
$ch = curl_init('https://api.stripe.com/v1/checkout/sessions');
27-
curl_setopt_array($ch, [
28-
CURLOPT_RETURNTRANSFER => true,
29-
CURLOPT_POST => true,
30-
CURLOPT_POSTFIELDS => http_build_query($fields),
31-
CURLOPT_USERPWD => $c['stripe_secret_key'] . ':', // secret key as basic-auth user
32-
CURLOPT_TIMEOUT => 20,
3350
]);
34-
$resp = curl_exec($ch);
35-
if ($resp === false) {
36-
return null;
37-
}
38-
$data = json_decode($resp, true);
3951
return $data['url'] ?? null;
4052
}
4153

@@ -56,8 +68,8 @@ function stripe_verify_webhook(string $payload, string $sigHeader, string
5668
if (!$t || !$sigs) {
5769
return false;
5870
}
59-
// Reject anything older than 5 minutes to limit replay.
60-
if (abs(time() - (int)$t) > 300) {
71+
// Reject anything too old to limit replay.
72+
if (abs(time() - (int)$t) > STRIPE_WEBHOOK_TOLERANCE) {
6173
return false;
6274
}
6375
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);
@@ -77,56 +89,24 @@ function stripe_portal_url(array $user): ?string
7789
if (empty($user['stripe_id']) || !stripe_enabled()) {
7890
return null;
7991
}
80-
$fields = [
92+
$data = stripe_api('POST', 'billing_portal/sessions', [
8193
'customer' => $user['stripe_id'],
8294
'return_url' => $c['base_url'] . '/app',
83-
];
84-
$ch = curl_init('https://api.stripe.com/v1/billing_portal/sessions');
85-
curl_setopt_array($ch, [
86-
CURLOPT_RETURNTRANSFER => true,
87-
CURLOPT_POST => true,
88-
CURLOPT_POSTFIELDS => http_build_query($fields),
89-
CURLOPT_USERPWD => $c['stripe_secret_key'] . ':',
90-
CURLOPT_TIMEOUT => 20,
9195
]);
92-
$resp = curl_exec($ch);
93-
if ($resp === false) {
94-
return null;
95-
}
96-
$data = json_decode($resp, true);
9796
return $data['url'] ?? null;
9897
}
9998

10099
// Cancel any active subscriptions for the user's Stripe customer. No-op when
101100
// Stripe is unconfigured or the user has no customer id.
102101
function stripe_cancel_subscription(array $user): void
103102
{
104-
$c = config();
105103
if (empty($user['stripe_id']) || !stripe_enabled()) {
106104
return;
107105
}
108-
$ch = curl_init('https://api.stripe.com/v1/subscriptions?customer=' . urlencode((string)$user['stripe_id']) . '&status=active');
109-
curl_setopt_array($ch, [
110-
CURLOPT_RETURNTRANSFER => true,
111-
CURLOPT_USERPWD => $c['stripe_secret_key'] . ':',
112-
CURLOPT_TIMEOUT => 20,
113-
]);
114-
$resp = curl_exec($ch);
115-
if ($resp === false) {
116-
return;
117-
}
118-
$list = json_decode($resp, true);
106+
$list = stripe_api('GET', 'subscriptions?customer=' . urlencode((string)$user['stripe_id']) . '&status=active');
119107
foreach ($list['data'] ?? [] as $sub) {
120-
if (empty($sub['id'])) {
121-
continue;
108+
if (!empty($sub['id'])) {
109+
stripe_api('DELETE', 'subscriptions/' . $sub['id']);
122110
}
123-
$del = curl_init('https://api.stripe.com/v1/subscriptions/' . $sub['id']);
124-
curl_setopt_array($del, [
125-
CURLOPT_RETURNTRANSFER => true,
126-
CURLOPT_CUSTOMREQUEST => 'DELETE',
127-
CURLOPT_USERPWD => $c['stripe_secret_key'] . ':',
128-
CURLOPT_TIMEOUT => 20,
129-
]);
130-
curl_exec($del);
131111
}
132112
}

src/lib/layout.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
// Shared HTML chrome. Plain functions, no template engine.
44
declare(strict_types=1);
55

6+
// True when a one-shot status flag is present in the query string,
7+
// e.g. after a POST redirect to ...?saved=1. Keeps the ad-hoc isset() reads
8+
// out of the page files.
9+
function flash(string $key): bool
10+
{
11+
return isset($_GET[$key]);
12+
}
13+
614
function layout_header(string $title = 'Leanplate', string $description = '', string $ogImage = ''): void
715
{
816
$cfg = config();
@@ -23,7 +31,7 @@ function layout_header(string $title = 'Leanplate', string $description = '', st
2331
: '<a href="/auth/login">Sign in</a>';
2432

2533
// Toast shown after the feedback modal posts (?fb=1 on any page).
26-
$toast = isset($_GET['fb']) ? '<div class="toast" role="status">Thanks for the feedback.</div>' : '';
34+
$toast = flash('fb') ? '<div class="toast" role="status">Thanks for the feedback.</div>' : '';
2735

2836
echo <<<HTML
2937
<!doctype html>
@@ -69,6 +77,7 @@ function layout_footer(): void
6977
$ver = $version !== '' ? '<p class="version">v' . htmlspecialchars($version) . '</p>' : '';
7078
// Trusted operator config (GA/Plausible/etc.) - intentionally not escaped.
7179
$snippet = (string)($cfg['analytics_snippet'] ?? '');
80+
$csrf = csrf_field();
7281
echo <<<HTML
7382
</main>
7483
<footer class="site-footer">
@@ -78,6 +87,7 @@ function layout_footer(): void
7887
<dialog id="fb-modal" class="modal">
7988
<h2>Feedback</h2>
8089
<form method="post" action="/feedback">
90+
$csrf
8191
<label for="fb-message">What's on your mind?</label>
8292
<textarea id="fb-message" name="message" required></textarea>
8393
<label for="fb-email">Email (optional)</label>

0 commit comments

Comments
 (0)