Mbonea commited on
Commit
58d7be4
Β·
1 Parent(s): e883d0c

Fix captive portal purchase throttling

Browse files
Files changed (2) hide show
  1. public/engine.js +10 -0
  2. src/middleware/rateLimiter.js +9 -0
public/engine.js CHANGED
@@ -41,6 +41,7 @@
41
  let selectedPlanPrice = null;
42
  let pollTimer = null;
43
  let currentReference = null;
 
44
 
45
  // ── Plan selection ────────────────────────────────────────────────────────
46
  const planRadios = document.querySelectorAll('input[name="plan"]');
@@ -89,6 +90,7 @@
89
  // ── Pay button ────────────────────────────────────────────────────────────
90
  if (payBtn) {
91
  payBtn.addEventListener('click', async () => {
 
92
  clearError('select-error');
93
  const phone = phoneInput?.value?.trim();
94
  if (!selectedPlanId) return showError('select-error', 'Please select a plan.');
@@ -96,6 +98,7 @@
96
  return showError('select-error', 'Enter a valid phone number.');
97
  }
98
 
 
99
  payBtn.disabled = true;
100
  showScreen('waiting');
101
 
@@ -109,6 +112,7 @@
109
 
110
  if (!res.ok || !data.reference) {
111
  showScreen('select');
 
112
  payBtn.disabled = false;
113
  return showError('select-error', data.error || 'Purchase failed. Try again.');
114
  }
@@ -117,6 +121,7 @@
117
  startPolling(data.reference);
118
  } catch (err) {
119
  showScreen('select');
 
120
  payBtn.disabled = false;
121
  showError('select-error', 'Network error. Please try again.');
122
  }
@@ -133,6 +138,7 @@
133
  attempts++;
134
  if (attempts > MAX) {
135
  clearInterval(pollTimer);
 
136
  showScreen('select');
137
  if (payBtn) payBtn.disabled = false;
138
  showError('select-error', 'Payment timed out. Please try again.');
@@ -148,6 +154,7 @@
148
  await authorizeWithCode(data.code);
149
  } else if (data.status === 'failed') {
150
  clearInterval(pollTimer);
 
151
  showScreen('select');
152
  if (payBtn) payBtn.disabled = false;
153
  showError('select-error', 'Payment was declined. Please try again.');
@@ -161,6 +168,7 @@
161
  if (cancelBtn) {
162
  cancelBtn.addEventListener('click', () => {
163
  clearInterval(pollTimer);
 
164
  showScreen('select');
165
  if (payBtn) payBtn.disabled = false;
166
  });
@@ -177,6 +185,7 @@
177
  const data = await res.json();
178
 
179
  if (!res.ok || !data.success) {
 
180
  showScreen('select');
181
  if (payBtn) payBtn.disabled = false;
182
  showError('select-error', data.error || 'Authorization failed. Enter your code manually.');
@@ -185,6 +194,7 @@
185
 
186
  showSuccess(data);
187
  } catch (err) {
 
188
  // Show code entry as fallback so guest can manually enter
189
  showCodeEntry(code);
190
  }
 
41
  let selectedPlanPrice = null;
42
  let pollTimer = null;
43
  let currentReference = null;
44
+ let purchaseInFlight = false;
45
 
46
  // ── Plan selection ────────────────────────────────────────────────────────
47
  const planRadios = document.querySelectorAll('input[name="plan"]');
 
90
  // ── Pay button ────────────────────────────────────────────────────────────
91
  if (payBtn) {
92
  payBtn.addEventListener('click', async () => {
93
+ if (purchaseInFlight) return;
94
  clearError('select-error');
95
  const phone = phoneInput?.value?.trim();
96
  if (!selectedPlanId) return showError('select-error', 'Please select a plan.');
 
98
  return showError('select-error', 'Enter a valid phone number.');
99
  }
100
 
101
+ purchaseInFlight = true;
102
  payBtn.disabled = true;
103
  showScreen('waiting');
104
 
 
112
 
113
  if (!res.ok || !data.reference) {
114
  showScreen('select');
115
+ purchaseInFlight = false;
116
  payBtn.disabled = false;
117
  return showError('select-error', data.error || 'Purchase failed. Try again.');
118
  }
 
121
  startPolling(data.reference);
122
  } catch (err) {
123
  showScreen('select');
124
+ purchaseInFlight = false;
125
  payBtn.disabled = false;
126
  showError('select-error', 'Network error. Please try again.');
127
  }
 
138
  attempts++;
139
  if (attempts > MAX) {
140
  clearInterval(pollTimer);
141
+ purchaseInFlight = false;
142
  showScreen('select');
143
  if (payBtn) payBtn.disabled = false;
144
  showError('select-error', 'Payment timed out. Please try again.');
 
154
  await authorizeWithCode(data.code);
155
  } else if (data.status === 'failed') {
156
  clearInterval(pollTimer);
157
+ purchaseInFlight = false;
158
  showScreen('select');
159
  if (payBtn) payBtn.disabled = false;
160
  showError('select-error', 'Payment was declined. Please try again.');
 
168
  if (cancelBtn) {
169
  cancelBtn.addEventListener('click', () => {
170
  clearInterval(pollTimer);
171
+ purchaseInFlight = false;
172
  showScreen('select');
173
  if (payBtn) payBtn.disabled = false;
174
  });
 
185
  const data = await res.json();
186
 
187
  if (!res.ok || !data.success) {
188
+ purchaseInFlight = false;
189
  showScreen('select');
190
  if (payBtn) payBtn.disabled = false;
191
  showError('select-error', data.error || 'Authorization failed. Enter your code manually.');
 
194
 
195
  showSuccess(data);
196
  } catch (err) {
197
+ purchaseInFlight = false;
198
  // Show code entry as fallback so guest can manually enter
199
  showCodeEntry(code);
200
  }
src/middleware/rateLimiter.js CHANGED
@@ -1,5 +1,9 @@
1
  const rateLimit = require('express-rate-limit');
2
 
 
 
 
 
3
  // Tenant login β€” 5 failed attempts per 15 min per IP
4
  const loginLimiter = rateLimit({
5
  windowMs: 15 * 60 * 1000,
@@ -25,6 +29,11 @@ const purchaseLimiter = rateLimit({
25
  max: 5,
26
  standardHeaders: true,
27
  legacyHeaders: false,
 
 
 
 
 
28
  message: { error: 'Too many purchase attempts. Please wait before trying again.' },
29
  });
30
 
 
1
  const rateLimit = require('express-rate-limit');
2
 
3
+ function normalizePhone(value) {
4
+ return String(value || '').replace(/\D/g, '');
5
+ }
6
+
7
  // Tenant login β€” 5 failed attempts per 15 min per IP
8
  const loginLimiter = rateLimit({
9
  windowMs: 15 * 60 * 1000,
 
29
  max: 5,
30
  standardHeaders: true,
31
  legacyHeaders: false,
32
+ keyGenerator: req => {
33
+ const siteId = req.params?.siteId || 'unknown-site';
34
+ const phone = normalizePhone(req.body?.phone) || req.ip || 'unknown-phone';
35
+ return `${siteId}:${phone}`;
36
+ },
37
  message: { error: 'Too many purchase attempts. Please wait before trying again.' },
38
  });
39