Mbonea commited on
Commit
5fdf708
·
1 Parent(s): 69097c2

Record Omada voucher sessions reliably

Browse files
src/routes/portal.routes.js CHANGED
@@ -14,6 +14,7 @@ const { snippeWebhookUrl } = require('../utils/webhookUrl');
14
  const { decryptSecret } = require('../utils/paymentSecrets');
15
 
16
  const normalizeMac = mac => String(mac || '').toUpperCase().replace(/[:\-]/g, '');
 
17
 
18
  const omadaPortalErrorMap = {
19
  '-41501': 'Failed to authenticate.',
@@ -136,53 +137,71 @@ function numberOrUndefined(value) {
136
  }
137
 
138
  async function recordOmadaVoucherUse({ siteId, code, mac }) {
139
- const token = await db.query(
140
- `SELECT t.*, d.omada_site_id
141
- FROM access_tokens t
142
- JOIN devices d ON d.id = t.device_id
143
- WHERE BINARY t.code = ? AND d.omada_site_id = ? LIMIT 1`,
144
- [code, siteId]
145
- ).then(r => r[0]);
146
-
147
- if (!token) {
148
- console.warn('[portal/omada-auth-record] Omada accepted voucher but code is not in WiFiBiz DB:', {
149
  siteId,
150
- mac,
151
  code,
152
  });
153
- return { token: null, sessionRecorded: false };
154
  }
155
 
156
- const now = new Date();
157
- let expiresAt = token.expires_at ? new Date(token.expires_at) : null;
158
 
159
- if (token.status === 'revoked') {
160
- console.warn('[portal/omada-auth-record] Omada accepted revoked WiFiBiz token; leaving DB status revoked:', {
161
- siteId,
162
- mac,
163
- code,
164
- tokenId: token.id,
165
- });
166
- return { token, expiresAt, sessionRecorded: false, statusMismatch: 'revoked_but_omada_accepted' };
167
- }
168
 
169
- if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) {
170
- console.warn('[portal/omada-auth-record] Omada accepted token for a different MAC; syncing DB lock to Omada result:', {
171
- siteId,
172
- code,
173
- tokenId: token.id,
174
- previousMac: token.locked_mac,
175
- acceptedMac: mac,
176
- });
177
- }
178
 
179
- if (!expiresAt || expiresAt < now) {
180
- expiresAt = new Date(now.getTime() + token.duration_seconds * 1000);
181
- }
 
 
 
 
 
 
182
 
183
- if (token.status !== 'active' || token.locked_mac !== mac || !token.activated_at || !token.expires_at || new Date(token.expires_at) < now) {
184
- expiresAt = new Date(now.getTime() + token.duration_seconds * 1000);
185
- await db.query(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  `UPDATE access_tokens SET
187
  status = 'active',
188
  locked_mac = ?,
@@ -191,29 +210,53 @@ async function recordOmadaVoucherUse({ siteId, code, mac }) {
191
  WHERE id = ?`,
192
  [mac, now, expiresAt, token.id]
193
  );
194
- }
195
-
196
- const activeSession = await db.query(
197
- 'SELECT id FROM sessions WHERE access_token_id = ? AND client_mac = ? AND is_active = 1 LIMIT 1',
198
- [token.id, mac]
199
- ).then(r => r[0]);
200
 
201
- if (activeSession) {
202
- await db.query(
203
- `UPDATE sessions
204
- SET last_seen_at = NOW(), ended_at = NULL, is_active = 1
205
- WHERE id = ?`,
206
- [activeSession.id]
207
  );
208
- } else {
209
- await db.query(
210
- `INSERT INTO sessions (access_token_id, client_id, device_id, client_mac, started_at, last_seen_at, is_active)
211
- VALUES (?, ?, ?, ?, NOW(), NOW(), 1)`,
212
- [token.id, token.client_id, token.device_id, mac]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  );
214
- }
215
 
216
- return { token, expiresAt, sessionRecorded: true };
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  }
218
 
219
  function applyRateLimitLater(siteId, mac, token) {
@@ -589,22 +632,57 @@ router.get('/:siteId/check/:reference', async (req, res) => {
589
  // POST /portal/:siteId/omada-auth-record - record Omada-native voucher success in WiFiBiz
590
  router.post('/:siteId/omada-auth-record', portalAuthLimiter, async (req, res) => {
591
  const { siteId } = req.params;
592
- const { code, clientMac } = req.body;
 
 
 
 
 
 
 
 
 
593
 
594
- if (!code || !clientMac) {
595
- return res.status(400).json({ error: 'code and clientMac required' });
596
  }
597
 
598
  const normalizedCode = String(code).trim();
599
- const mac = clientMac.toUpperCase().replace(/:/g, '-');
 
 
 
 
 
 
 
 
 
 
600
 
601
  try {
602
  const tokenMeta = await recordOmadaVoucherUse({ siteId, code: normalizedCode, mac });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  console.log('[portal/omada-auth-record] recorded Omada voucher success:', {
604
- siteId,
605
- mac,
606
  code: normalizedCode,
607
  tokenId: tokenMeta.token?.id || null,
 
 
 
608
  sessionRecorded: tokenMeta.sessionRecorded,
609
  expiresAt: tokenMeta.expiresAt || null,
610
  statusMismatch: tokenMeta.statusMismatch || null,
@@ -612,18 +690,25 @@ router.post('/:siteId/omada-auth-record', portalAuthLimiter, async (req, res) =>
612
  return res.json({
613
  success: true,
614
  tokenId: tokenMeta.token?.id || null,
 
 
 
615
  sessionRecorded: tokenMeta.sessionRecorded,
616
  expiresAt: tokenMeta.expiresAt || null,
617
  statusMismatch: tokenMeta.statusMismatch || null,
618
  });
619
  } catch (err) {
620
  console.error('[portal/omada-auth-record] failed:', {
621
- siteId,
622
- mac,
623
  code: normalizedCode,
 
624
  message: err.message,
625
  });
626
- return res.status(500).json({ error: 'Failed to record Omada voucher usage' });
 
 
 
 
627
  }
628
  });
629
 
@@ -645,7 +730,7 @@ router.post('/:siteId/omada-auth', portalAuthLimiter, async (req, res) => {
645
  }
646
 
647
  const normalizedCode = String(code).trim();
648
- const mac = clientMac.toUpperCase().replace(/:/g, '-');
649
  const payload = {
650
  authType: 3,
651
  voucherCode: normalizedCode,
 
14
  const { decryptSecret } = require('../utils/paymentSecrets');
15
 
16
  const normalizeMac = mac => String(mac || '').toUpperCase().replace(/[:\-]/g, '');
17
+ const normalizePortalMac = mac => String(mac || '').trim().toUpperCase().replace(/:/g, '-');
18
 
19
  const omadaPortalErrorMap = {
20
  '-41501': 'Failed to authenticate.',
 
137
  }
138
 
139
  async function recordOmadaVoucherUse({ siteId, code, mac }) {
140
+ if (!mac) {
141
+ const err = new Error('clientMac is required to record Omada voucher usage');
142
+ err.status = 400;
143
+ err.code = 'CLIENT_MAC_REQUIRED';
144
+ console.warn('[portal/omada-auth-record] missing clientMac; cannot attach voucher to device:', {
 
 
 
 
 
145
  siteId,
 
146
  code,
147
  });
148
+ throw err;
149
  }
150
 
151
+ const conn = await db.pool.getConnection();
 
152
 
153
+ try {
154
+ await conn.beginTransaction();
 
 
 
 
 
 
 
155
 
156
+ const [rows] = await conn.execute(
157
+ `SELECT t.*, d.omada_site_id
158
+ FROM access_tokens t
159
+ JOIN devices d ON d.id = t.device_id
160
+ WHERE BINARY t.code = ? AND d.omada_site_id = ? LIMIT 1
161
+ FOR UPDATE`,
162
+ [code, siteId]
163
+ );
164
+ const token = rows[0];
165
 
166
+ if (!token) {
167
+ await conn.rollback();
168
+ console.warn('[portal/omada-auth-record] Omada accepted voucher but code is not in WiFiBiz DB:', {
169
+ siteId,
170
+ mac,
171
+ code,
172
+ });
173
+ return { token: null, sessionRecorded: false };
174
+ }
175
 
176
+ const now = new Date();
177
+ let expiresAt = token.expires_at ? new Date(token.expires_at) : null;
178
+
179
+ if (token.status === 'revoked') {
180
+ await conn.rollback();
181
+ console.warn('[portal/omada-auth-record] Omada accepted revoked WiFiBiz token; leaving DB status revoked:', {
182
+ siteId,
183
+ mac,
184
+ code,
185
+ tokenId: token.id,
186
+ });
187
+ return { token, expiresAt, sessionRecorded: false, statusMismatch: 'revoked_but_omada_accepted' };
188
+ }
189
+
190
+ if (token.locked_mac && token.locked_mac.toUpperCase() !== mac) {
191
+ console.warn('[portal/omada-auth-record] Omada accepted token for a different MAC; syncing DB lock to Omada result:', {
192
+ siteId,
193
+ code,
194
+ tokenId: token.id,
195
+ previousMac: token.locked_mac,
196
+ acceptedMac: mac,
197
+ });
198
+ }
199
+
200
+ if (!expiresAt || expiresAt < now) {
201
+ expiresAt = new Date(now.getTime() + token.duration_seconds * 1000);
202
+ }
203
+
204
+ await conn.execute(
205
  `UPDATE access_tokens SET
206
  status = 'active',
207
  locked_mac = ?,
 
210
  WHERE id = ?`,
211
  [mac, now, expiresAt, token.id]
212
  );
 
 
 
 
 
 
213
 
214
+ const [activeSessionRows] = await conn.execute(
215
+ 'SELECT id FROM sessions WHERE access_token_id = ? AND client_mac = ? AND is_active = 1 LIMIT 1',
216
+ [token.id, mac]
 
 
 
217
  );
218
+ const activeSession = activeSessionRows[0];
219
+
220
+ let sessionId = null;
221
+ if (activeSession) {
222
+ sessionId = activeSession.id;
223
+ await conn.execute(
224
+ `UPDATE sessions
225
+ SET last_seen_at = NOW(), ended_at = NULL, is_active = 1
226
+ WHERE id = ?`,
227
+ [activeSession.id]
228
+ );
229
+ } else {
230
+ const [insertResult] = await conn.execute(
231
+ `INSERT INTO sessions (access_token_id, client_id, device_id, client_mac, started_at, last_seen_at, is_active)
232
+ VALUES (?, ?, ?, ?, NOW(), NOW(), 1)`,
233
+ [token.id, token.client_id, token.device_id, mac]
234
+ );
235
+ sessionId = insertResult.insertId;
236
+ }
237
+
238
+ const [updatedRows] = await conn.execute(
239
+ `SELECT t.*, d.omada_site_id
240
+ FROM access_tokens t
241
+ JOIN devices d ON d.id = t.device_id
242
+ WHERE t.id = ? LIMIT 1`,
243
+ [token.id]
244
  );
 
245
 
246
+ await conn.commit();
247
+
248
+ return {
249
+ token: updatedRows[0] || { ...token, status: 'active', locked_mac: mac, expires_at: expiresAt },
250
+ expiresAt,
251
+ sessionRecorded: true,
252
+ sessionId,
253
+ };
254
+ } catch (err) {
255
+ await conn.rollback();
256
+ throw err;
257
+ } finally {
258
+ conn.release();
259
+ }
260
  }
261
 
262
  function applyRateLimitLater(siteId, mac, token) {
 
632
  // POST /portal/:siteId/omada-auth-record - record Omada-native voucher success in WiFiBiz
633
  router.post('/:siteId/omada-auth-record', portalAuthLimiter, async (req, res) => {
634
  const { siteId } = req.params;
635
+ const {
636
+ code,
637
+ clientMac,
638
+ apMac,
639
+ gatewayMac,
640
+ ssidName,
641
+ radioId,
642
+ vid,
643
+ originUrl,
644
+ } = req.body;
645
 
646
+ if (!code) {
647
+ return res.status(400).json({ error: 'code required', code: 'CODE_REQUIRED' });
648
  }
649
 
650
  const normalizedCode = String(code).trim();
651
+ const mac = normalizePortalMac(clientMac);
652
+ const identifiers = {
653
+ siteId,
654
+ mac: mac || null,
655
+ apMac: apMac || null,
656
+ gatewayMac: gatewayMac || null,
657
+ ssidName: ssidName || null,
658
+ radioId: numberOrUndefined(radioId) ?? null,
659
+ vid: numberOrUndefined(vid) ?? null,
660
+ originUrl: originUrl || null,
661
+ };
662
 
663
  try {
664
  const tokenMeta = await recordOmadaVoucherUse({ siteId, code: normalizedCode, mac });
665
+
666
+ if (!tokenMeta.token) {
667
+ console.warn('[portal/omada-auth-record] Omada accepted voucher but WiFiBiz has no matching token row:', {
668
+ ...identifiers,
669
+ code: normalizedCode,
670
+ });
671
+ return res.status(404).json({
672
+ success: false,
673
+ error: 'Voucher was accepted by Omada but was not found in WiFiBiz',
674
+ code: 'TOKEN_NOT_FOUND',
675
+ sessionRecorded: false,
676
+ });
677
+ }
678
+
679
  console.log('[portal/omada-auth-record] recorded Omada voucher success:', {
680
+ ...identifiers,
 
681
  code: normalizedCode,
682
  tokenId: tokenMeta.token?.id || null,
683
+ lockedMac: tokenMeta.token?.locked_mac || null,
684
+ status: tokenMeta.token?.status || null,
685
+ sessionId: tokenMeta.sessionId || null,
686
  sessionRecorded: tokenMeta.sessionRecorded,
687
  expiresAt: tokenMeta.expiresAt || null,
688
  statusMismatch: tokenMeta.statusMismatch || null,
 
690
  return res.json({
691
  success: true,
692
  tokenId: tokenMeta.token?.id || null,
693
+ lockedMac: tokenMeta.token?.locked_mac || null,
694
+ status: tokenMeta.token?.status || null,
695
+ sessionId: tokenMeta.sessionId || null,
696
  sessionRecorded: tokenMeta.sessionRecorded,
697
  expiresAt: tokenMeta.expiresAt || null,
698
  statusMismatch: tokenMeta.statusMismatch || null,
699
  });
700
  } catch (err) {
701
  console.error('[portal/omada-auth-record] failed:', {
702
+ ...identifiers,
 
703
  code: normalizedCode,
704
+ errorCode: err.code || null,
705
  message: err.message,
706
  });
707
+ const status = err.status || 500;
708
+ return res.status(status).json({
709
+ error: status === 500 ? 'Failed to record Omada voucher usage' : err.message,
710
+ code: err.code || 'OMADA_RECORD_FAILED',
711
+ });
712
  }
713
  });
714
 
 
730
  }
731
 
732
  const normalizedCode = String(code).trim();
733
+ const mac = normalizePortalMac(clientMac);
734
  const payload = {
735
  authType: 3,
736
  voucherCode: normalizedCode,
v2 Test/portal-redirect.html CHANGED
@@ -27,6 +27,36 @@
27
  text-align: center;
28
  }
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  a {
31
  color: #0369a1;
32
  }
@@ -60,6 +90,8 @@
60
  </head>
61
  <body>
62
  <main>
 
 
63
  <p id="status-text">Redirecting to the portal...</p>
64
  <p><a id="fallback-link" href="#">Continue to portal</a></p>
65
  <form id="auth-form" class="auth-form">
@@ -154,6 +186,7 @@
154
  function postJson(url, data, callback) {
155
  var xhr = new XMLHttpRequest();
156
  xhr.open("POST", url, true);
 
157
  xhr.setRequestHeader("Content-Type", "application/json");
158
  xhr.onreadystatechange = function () {
159
  if (xhr.readyState !== 4) return;
@@ -165,25 +198,56 @@
165
  }
166
  callback(xhr.status, parsed);
167
  };
 
 
 
 
 
 
168
  xhr.send(JSON.stringify(data));
169
  }
170
 
171
  function recordVoucherUse(code, callback) {
172
- var called = false;
173
- function done() {
174
- if (called) return;
175
- called = true;
176
- callback();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  }
178
 
179
- setTimeout(done, 1200);
180
- postJson(portalBase() + "/portal/" + encodeURIComponent(findSiteId()) + "/omada-auth-record", {
181
- code: code,
182
- clientMac: sourceParams.get("clientMac") || undefined
183
- }, done);
184
  }
185
 
186
  function showRetry(message, code) {
 
187
  statusText.textContent = message || "Voucher validation failed.";
188
  fallbackLink.href = cleanReturnUrl();
189
  fallbackLink.textContent = "Back to plans";
@@ -195,6 +259,16 @@
195
  authCodeButton.textContent = "Try code";
196
  }
197
 
 
 
 
 
 
 
 
 
 
 
198
  function authenticateWithOmada(code) {
199
  statusText.textContent = "Validating voucher with Omada...";
200
  fallbackLink.style.display = "none";
@@ -217,10 +291,22 @@
217
 
218
  postJson("/portal/auth", payload, function (_status, data) {
219
  if (data && data.errorCode === 0) {
220
- statusText.textContent = "Connected. Saving session...";
221
- recordVoucherUse(code, function () {
222
- statusText.textContent = "Connected. Opening landing page...";
223
- window.location.href = landingUrl(data.result || payload.originUrl);
 
 
 
 
 
 
 
 
 
 
 
 
224
  });
225
  return;
226
  }
 
27
  text-align: center;
28
  }
29
 
30
+ .status-mark {
31
+ display: none;
32
+ width: 56px;
33
+ height: 56px;
34
+ margin: 0 auto 16px;
35
+ border-radius: 50%;
36
+ background: #16a34a;
37
+ color: #fff;
38
+ font-size: 17px;
39
+ font-weight: 800;
40
+ line-height: 56px;
41
+ letter-spacing: 0;
42
+ }
43
+
44
+ .success-title {
45
+ display: none;
46
+ margin: 0 0 8px;
47
+ font-size: 24px;
48
+ line-height: 1.2;
49
+ }
50
+
51
+ .is-success .status-mark,
52
+ .is-success .success-title {
53
+ display: block;
54
+ }
55
+
56
+ .is-success #auth-form {
57
+ display: none !important;
58
+ }
59
+
60
  a {
61
  color: #0369a1;
62
  }
 
90
  </head>
91
  <body>
92
  <main>
93
+ <div id="status-mark" class="status-mark">OK</div>
94
+ <h1 id="success-title" class="success-title">You are connected</h1>
95
  <p id="status-text">Redirecting to the portal...</p>
96
  <p><a id="fallback-link" href="#">Continue to portal</a></p>
97
  <form id="auth-form" class="auth-form">
 
186
  function postJson(url, data, callback) {
187
  var xhr = new XMLHttpRequest();
188
  xhr.open("POST", url, true);
189
+ xhr.timeout = 8000;
190
  xhr.setRequestHeader("Content-Type", "application/json");
191
  xhr.onreadystatechange = function () {
192
  if (xhr.readyState !== 4) return;
 
198
  }
199
  callback(xhr.status, parsed);
200
  };
201
+ xhr.onerror = function () {
202
+ callback(0, { msg: "Network request failed" });
203
+ };
204
+ xhr.ontimeout = function () {
205
+ callback(0, { msg: "Network request timed out" });
206
+ };
207
  xhr.send(JSON.stringify(data));
208
  }
209
 
210
  function recordVoucherUse(code, callback) {
211
+ var recordUrl = portalBase() + "/portal/" + encodeURIComponent(findSiteId()) + "/omada-auth-record";
212
+ var payload = {
213
+ code: code,
214
+ clientMac: sourceParams.get("clientMac") || undefined,
215
+ apMac: sourceParams.get("apMac") || undefined,
216
+ gatewayMac: sourceParams.get("gatewayMac") || undefined,
217
+ ssidName: sourceParams.get("ssidName") || undefined,
218
+ radioId: numberOrUndefined(sourceParams.get("radioId")),
219
+ vid: numberOrUndefined(sourceParams.get("vid")),
220
+ originUrl: sourceParams.get("originUrl") || undefined
221
+ };
222
+ var attempts = 0;
223
+ var delays = [0, 700, 1600];
224
+
225
+ function attemptRecord() {
226
+ attempts += 1;
227
+ statusText.textContent = attempts === 1
228
+ ? "Connected. Saving session..."
229
+ : "Connected. Saving session again...";
230
+
231
+ postJson(recordUrl, payload, function (status, data) {
232
+ if (status >= 200 && status < 300 && data && data.success && data.sessionRecorded) {
233
+ callback(null, data);
234
+ return;
235
+ }
236
+
237
+ if (attempts < delays.length) {
238
+ window.setTimeout(attemptRecord, delays[attempts]);
239
+ return;
240
+ }
241
+
242
+ callback(data || { msg: "Session save failed" });
243
+ });
244
  }
245
 
246
+ window.setTimeout(attemptRecord, delays[0]);
 
 
 
 
247
  }
248
 
249
  function showRetry(message, code) {
250
+ document.body.className = "";
251
  statusText.textContent = message || "Voucher validation failed.";
252
  fallbackLink.href = cleanReturnUrl();
253
  fallbackLink.textContent = "Back to plans";
 
259
  authCodeButton.textContent = "Try code";
260
  }
261
 
262
+ function showSuccess(nextUrl) {
263
+ document.body.className = "is-success";
264
+ statusText.textContent = "Voucher accepted. Your WiFi session has been saved.";
265
+ fallbackLink.href = nextUrl;
266
+ fallbackLink.textContent = "Continue";
267
+ fallbackLink.style.display = "";
268
+ authCodeButton.disabled = true;
269
+ authCodeButton.textContent = "Connected";
270
+ }
271
+
272
  function authenticateWithOmada(code) {
273
  statusText.textContent = "Validating voucher with Omada...";
274
  fallbackLink.style.display = "none";
 
291
 
292
  postJson("/portal/auth", payload, function (_status, data) {
293
  if (data && data.errorCode === 0) {
294
+ recordVoucherUse(code, function (recordError) {
295
+ if (recordError) {
296
+ statusText.textContent = (recordError && (recordError.msg || recordError.error)) || "Connected, but WiFiBiz could not save this session.";
297
+ fallbackLink.href = landingUrl(data.result || payload.originUrl);
298
+ fallbackLink.textContent = "Continue";
299
+ fallbackLink.style.display = "";
300
+ authCodeButton.disabled = false;
301
+ authCodeButton.textContent = "Try code";
302
+ return;
303
+ }
304
+
305
+ var nextUrl = landingUrl(data.result || payload.originUrl);
306
+ showSuccess(nextUrl);
307
+ window.setTimeout(function () {
308
+ window.location.href = nextUrl;
309
+ }, 1800);
310
  });
311
  return;
312
  }