diff options
| author | Your Name <you@example.com> | 2026-05-16 13:02:23 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-16 13:02:23 +0530 |
| commit | 236b61d619f60d941119d891dc1c6a49b504a880 (patch) | |
| tree | 3f5faffc36284d8082fe1a468eecdb89c3b9e11a | |
| parent | 38aa9ec3801f5895e09866fe92cb8e44fb987cee (diff) | |
Fix captive portal detection on GrapheneOS + embed mint URL in portal HTML
- Add esp_netif_set_dns_info() on AP interface so DHCP advertises
AP as DNS server to clients (fixes captive portal on GrapheneOS)
- Embed price and mint URL directly in portal HTML via server-side
template substitution (no JavaScript fetch to :2121 needed)
- Move supported mints section below the token input field
- Add Playwright tests: no unresolved placeholders, embedded mint/price,
DOM order verification (14/14 passing)
| -rw-r--r-- | main/captive_portal.c | 91 | ||||
| -rw-r--r-- | main/tollgate_main.c | 6 | ||||
| -rw-r--r-- | tests/captive-portal.spec.mjs | 38 |
3 files changed, 97 insertions, 38 deletions
diff --git a/main/captive_portal.c b/main/captive_portal.c index 1f7340e..7d6c885 100644 --- a/main/captive_portal.c +++ b/main/captive_portal.c | |||
| @@ -31,56 +31,49 @@ static const char PORTAL_HTML_TEMPLATE[] = \ | |||
| 31 | ".price{background:#252525;border-radius:12px;padding:16px;margin-bottom:16px}" | 31 | ".price{background:#252525;border-radius:12px;padding:16px;margin-bottom:16px}" |
| 32 | ".price-amount{font-size:36px;font-weight:bold;color:#f7931a}" | 32 | ".price-amount{font-size:36px;font-weight:bold;color:#f7931a}" |
| 33 | ".price-unit{color:#888;font-size:14px}" | 33 | ".price-unit{color:#888;font-size:14px}" |
| 34 | ".mints{background:#252525;border-radius:12px;padding:12px;margin-bottom:16px;text-align:left}" | 34 | "textarea{width:100%;height:80px;background:#252525;border:1px solid #333;border-radius:8px;" |
| 35 | "color:#fff;padding:12px;font-family:monospace;font-size:12px;resize:none}" | ||
| 36 | ".btn{background:#f7931a;color:#000;border:none;border-radius:8px;padding:14px 28px;" | ||
| 37 | "font-size:16px;font-weight:bold;cursor:pointer;width:100%;margin-top:8px}" | ||
| 38 | ".btn:hover{background:#e8850f}" | ||
| 39 | ".btn:disabled{background:#333;color:#666;cursor:not-allowed}" | ||
| 40 | ".mints{background:#252525;border-radius:12px;padding:12px;margin-top:16px;text-align:left}" | ||
| 35 | ".mints-title{color:#888;font-size:12px;margin-bottom:8px}" | 41 | ".mints-title{color:#888;font-size:12px;margin-bottom:8px}" |
| 36 | ".mint-url{font-family:monospace;font-size:11px;color:#f7931a;word-break:break-all;" | 42 | ".mint-url{font-family:monospace;font-size:11px;color:#f7931a;word-break:break-all;" |
| 37 | "background:#1a1a1a;padding:8px;border-radius:6px;position:relative;cursor:pointer}" | 43 | "background:#1a1a1a;padding:8px;border-radius:6px;cursor:pointer}" |
| 38 | ".mint-url:active{opacity:0.7}" | 44 | ".mint-url:active{opacity:0.7}" |
| 39 | ".mint-hint{color:#666;font-size:10px;margin-top:4px}" | 45 | ".mint-hint{color:#666;font-size:10px;margin-top:4px}" |
| 40 | "#status{margin-top:16px;padding:12px;border-radius:8px;display:none;font-size:14px}" | 46 | "#status{margin-top:16px;padding:12px;border-radius:8px;display:none;font-size:14px}" |
| 41 | "#status.success{display:block;background:#1a472a;color:#4caf50}" | 47 | "#status.success{display:block;background:#1a472a;color:#4caf50}" |
| 42 | "#status.error{display:block;background:#471a1a;color:#f44336}" | 48 | "#status.error{display:block;background:#471a1a;color:#f44336}" |
| 43 | "#status.processing{display:block;background:#1a3a47;color:#2196f3}" | 49 | "#status.processing{display:block;background:#1a3a47;color:#2196f3}" |
| 44 | ".btn{background:#f7931a;color:#000;border:none;border-radius:8px;padding:14px 28px;" | ||
| 45 | "font-size:16px;font-weight:bold;cursor:pointer;width:100%;margin-top:8px}" | ||
| 46 | ".btn:hover{background:#e8850f}" | ||
| 47 | ".btn:disabled{background:#333;color:#666;cursor:not-allowed}" | ||
| 48 | "textarea{width:100%;height:80px;background:#252525;border:1px solid #333;border-radius:8px;" | ||
| 49 | "color:#fff;padding:12px;font-family:monospace;font-size:12px;margin-top:8px;resize:none}" | ||
| 50 | "</style>" | 50 | "</style>" |
| 51 | "</head><body>" | 51 | "</head><body>" |
| 52 | "<div class='card'>" | 52 | "<div class='card'>" |
| 53 | "<h1>TollGate</h1>" | 53 | "<h1>TollGate</h1>" |
| 54 | "<p class='subtitle'>Pay for internet access with ecash</p>" | 54 | "<p class='subtitle'>Pay for internet access with ecash</p>" |
| 55 | "<div class='price'>" | 55 | "<div class='price'>" |
| 56 | "<div class='price-amount' id='price'>Loading...</div>" | 56 | "<div class='price-amount'>__PRICE__</div>" |
| 57 | "<div class='price-unit'>sats per minute</div>" | 57 | "<div class='price-unit'>sats per minute</div>" |
| 58 | "</div>" | 58 | "</div>" |
| 59 | "<textarea id='tokenInput' placeholder='Paste your Cashu token here (cashuA...)'></textarea>" | ||
| 60 | "<button class='btn' id='payBtn' onclick='payToken()'>Pay & Connect</button>" | ||
| 59 | "<div class='mints'>" | 61 | "<div class='mints'>" |
| 60 | "<div class='mints-title'>SUPPORTED MINTS</div>" | 62 | "<div class='mints-title'>SUPPORTED MINTS</div>" |
| 61 | "<div class='mint-url' id='mintUrl' onclick='copyMint()'>Loading...</div>" | 63 | "<div class='mint-url' id='mintUrl' onclick='copyMint()'>__MINT_URL__</div>" |
| 62 | "<div class='mint-hint'>Tap to copy • Mint tokens at this URL before paying</div>" | 64 | "<div class='mint-hint'>Tap to copy • Mint tokens at this URL before paying</div>" |
| 63 | "</div>" | 65 | "</div>" |
| 64 | "<textarea id='tokenInput' placeholder='Paste your Cashu token here (cashuA...)'></textarea>" | ||
| 65 | "<button class='btn' id='payBtn' onclick='payToken()'>Pay & Connect</button>" | ||
| 66 | "<div id='status'></div>" | 66 | "<div id='status'></div>" |
| 67 | "</div>" | 67 | "</div>" |
| 68 | "<script>" | 68 | "<script>" |
| 69 | "const priceEl=document.getElementById('price');" | 69 | "const mintUrlEl=document.getElementById('mintUrl');" |
| 70 | "const mintUrl=mintUrlEl.textContent;" | ||
| 70 | "const statusEl=document.getElementById('status');" | 71 | "const statusEl=document.getElementById('status');" |
| 71 | "const payBtn=document.getElementById('payBtn');" | 72 | "const payBtn=document.getElementById('payBtn');" |
| 72 | "const tokenInput=document.getElementById('tokenInput');" | 73 | "const tokenInput=document.getElementById('tokenInput');" |
| 73 | "const mintUrlEl=document.getElementById('mintUrl');" | ||
| 74 | "fetch('http://__AP_IP__:2121/').then(r=>r.json()).then(d=>{" | ||
| 75 | "if(d.tags){" | ||
| 76 | "const p=d.tags.find(t=>t[0]==='price_per_step');if(p){priceEl.textContent=p[2]||'21';" | ||
| 77 | "if(p[4]){mintUrlEl.textContent=p[4];}}" | ||
| 78 | "}" | ||
| 79 | "}).catch(()=>{priceEl.textContent='21';mintUrlEl.textContent='Error loading mint URL';});" | ||
| 80 | "function copyMint(){" | 74 | "function copyMint(){" |
| 81 | "const url=mintUrlEl.textContent;" | 75 | "if(navigator.clipboard){navigator.clipboard.writeText(mintUrl);" |
| 82 | "if(navigator.clipboard){navigator.clipboard.writeText(url);" | 76 | "mintUrlEl.textContent='Copied!';setTimeout(()=>{mintUrlEl.textContent=mintUrl;},1000);}" |
| 83 | "mintUrlEl.textContent='Copied!';setTimeout(()=>{mintUrlEl.textContent=url;},1000);}" | ||
| 84 | "}" | 77 | "}" |
| 85 | "function showStatus(msg,type){statusEl.textContent=msg;statusEl.className=type;}" | 78 | "function showStatus(msg,type){statusEl.textContent=msg;statusEl.className=type;}" |
| 86 | "function payToken(){" | 79 | "function payToken(){" |
| @@ -119,15 +112,32 @@ static esp_err_t portal_handler(httpd_req_t *req) | |||
| 119 | ESP_LOGI(TAG, "GET %s from client", req->uri); | 112 | ESP_LOGI(TAG, "GET %s from client", req->uri); |
| 120 | httpd_resp_set_type(req, "text/html"); | 113 | httpd_resp_set_type(req, "text/html"); |
| 121 | 114 | ||
| 122 | char *html = NULL; | 115 | const tollgate_config_t *cfg = tollgate_config_get(); |
| 116 | char price_str[16]; | ||
| 117 | snprintf(price_str, sizeof(price_str), "%d", cfg->price_per_step); | ||
| 118 | |||
| 123 | const char *tpl = PORTAL_HTML_TEMPLATE; | 119 | const char *tpl = PORTAL_HTML_TEMPLATE; |
| 124 | size_t tpl_len = strlen(tpl); | 120 | size_t tpl_len = strlen(tpl); |
| 125 | int count = 0; | ||
| 126 | const char *p = tpl; | ||
| 127 | while ((p = strstr(p, "__AP_IP__")) != NULL) { count++; p += 9; } | ||
| 128 | 121 | ||
| 129 | size_t ip_len = strlen(s_ap_ip_str); | 122 | struct { const char *key; const char *val; } subs[] = { |
| 130 | html = malloc(tpl_len + count * (ip_len > 9 ? ip_len - 9 : 0) + 1); | 123 | { "__AP_IP__", s_ap_ip_str }, |
| 124 | { "__PRICE__", price_str }, | ||
| 125 | { "__MINT_URL__", cfg->mint_url }, | ||
| 126 | }; | ||
| 127 | int nsubs = sizeof(subs) / sizeof(subs[0]); | ||
| 128 | |||
| 129 | size_t extra = 0; | ||
| 130 | for (int i = 0; i < nsubs; i++) { | ||
| 131 | const char *p = tpl; | ||
| 132 | size_t klen = strlen(subs[i].key); | ||
| 133 | while ((p = strstr(p, subs[i].key)) != NULL) { | ||
| 134 | extra += strlen(subs[i].val) - klen; | ||
| 135 | p += klen; | ||
| 136 | } | ||
| 137 | } | ||
| 138 | |||
| 139 | size_t out_size = tpl_len + extra + 1; | ||
| 140 | char *html = malloc(out_size); | ||
| 131 | if (!html) { | 141 | if (!html) { |
| 132 | httpd_resp_send_500(req); | 142 | httpd_resp_send_500(req); |
| 133 | return ESP_OK; | 143 | return ESP_OK; |
| @@ -136,13 +146,22 @@ static esp_err_t portal_handler(httpd_req_t *req) | |||
| 136 | char *out = html; | 146 | char *out = html; |
| 137 | const char *src = tpl; | 147 | const char *src = tpl; |
| 138 | while (*src) { | 148 | while (*src) { |
| 139 | const char *found = strstr(src, "__AP_IP__"); | 149 | const char *earliest = NULL; |
| 140 | if (found) { | 150 | int ei = -1; |
| 141 | memcpy(out, src, found - src); | 151 | for (int i = 0; i < nsubs; i++) { |
| 142 | out += found - src; | 152 | const char *found = strstr(src, subs[i].key); |
| 143 | memcpy(out, s_ap_ip_str, ip_len); | 153 | if (found && (earliest == NULL || found < earliest)) { |
| 144 | out += ip_len; | 154 | earliest = found; |
| 145 | src = found + 9; | 155 | ei = i; |
| 156 | } | ||
| 157 | } | ||
| 158 | if (earliest) { | ||
| 159 | size_t vlen = strlen(subs[ei].val); | ||
| 160 | memcpy(out, src, earliest - src); | ||
| 161 | out += earliest - src; | ||
| 162 | memcpy(out, subs[ei].val, vlen); | ||
| 163 | out += vlen; | ||
| 164 | src = earliest + strlen(subs[ei].key); | ||
| 146 | } else { | 165 | } else { |
| 147 | strcpy(out, src); | 166 | strcpy(out, src); |
| 148 | out += strlen(src); | 167 | out += strlen(src); |
diff --git a/main/tollgate_main.c b/main/tollgate_main.c index 30fad8d..9d2c392 100644 --- a/main/tollgate_main.c +++ b/main/tollgate_main.c | |||
| @@ -156,6 +156,12 @@ static void wifi_create_ap_netif(void) | |||
| 156 | ESP_ERROR_CHECK(esp_netif_set_ip_info(s_ap_netif, &ip_info)); | 156 | ESP_ERROR_CHECK(esp_netif_set_ip_info(s_ap_netif, &ip_info)); |
| 157 | ESP_ERROR_CHECK(esp_netif_dhcps_start(s_ap_netif)); | 157 | ESP_ERROR_CHECK(esp_netif_dhcps_start(s_ap_netif)); |
| 158 | 158 | ||
| 159 | esp_netif_dns_info_t dns_info; | ||
| 160 | dns_info.ip.u_addr.ip4.addr = ip_info.ip.addr; | ||
| 161 | dns_info.ip.type = IPADDR_TYPE_V4; | ||
| 162 | esp_netif_set_dns_info(s_ap_netif, ESP_NETIF_DNS_MAIN, &dns_info); | ||
| 163 | ESP_LOGI(TAG, "AP DNS server set to " IPSTR, IP2STR(&ip_info.ip)); | ||
| 164 | |||
| 159 | dhcps_offer_t offer_dns = true; | 165 | dhcps_offer_t offer_dns = true; |
| 160 | esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_DOMAIN_NAME_SERVER, | 166 | esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_DOMAIN_NAME_SERVER, |
| 161 | &offer_dns, sizeof(offer_dns)); | 167 | &offer_dns, sizeof(offer_dns)); |
diff --git a/tests/captive-portal.spec.mjs b/tests/captive-portal.spec.mjs index bc7a1fa..9411183 100644 --- a/tests/captive-portal.spec.mjs +++ b/tests/captive-portal.spec.mjs | |||
| @@ -15,7 +15,41 @@ test.describe('Captive Portal - Phase 2', () => { | |||
| 15 | test('portal shows price from API', async ({ page }) => { | 15 | test('portal shows price from API', async ({ page }) => { |
| 16 | await page.goto(PORTAL_URL); | 16 | await page.goto(PORTAL_URL); |
| 17 | const priceEl = page.locator('.price-amount'); | 17 | const priceEl = page.locator('.price-amount'); |
| 18 | await expect(priceEl).not.toBeEmpty({ timeout: 5000 }); | 18 | await expect(priceEl).toHaveText(/\d+/, { timeout: 5000 }); |
| 19 | }); | ||
| 20 | |||
| 21 | test('portal embeds mint URL without JavaScript fetch', async ({ request }) => { | ||
| 22 | const resp = await request.fetch(PORTAL_URL); | ||
| 23 | const body = await resp.text(); | ||
| 24 | expect(body).not.toContain('Loading...'); | ||
| 25 | expect(body).not.toContain('Error loading mint URL'); | ||
| 26 | expect(body).toMatch(/testnut\.cashu\.space/); | ||
| 27 | }); | ||
| 28 | |||
| 29 | test('portal embeds price without JavaScript fetch', async ({ request }) => { | ||
| 30 | const resp = await request.fetch(PORTAL_URL); | ||
| 31 | const body = await resp.text(); | ||
| 32 | expect(body).not.toContain('__PRICE__'); | ||
| 33 | expect(body).toMatch(/price-amount['"]>\d+</); | ||
| 34 | }); | ||
| 35 | |||
| 36 | test('portal HTML has no unresolved template placeholders', async ({ request }) => { | ||
| 37 | const resp = await request.fetch(PORTAL_URL); | ||
| 38 | const body = await resp.text(); | ||
| 39 | expect(body).not.toContain('__AP_IP__'); | ||
| 40 | expect(body).not.toContain('__MINT_URL__'); | ||
| 41 | expect(body).not.toContain('__PRICE__'); | ||
| 42 | }); | ||
| 43 | |||
| 44 | test('mints section appears after token input in DOM order', async ({ page }) => { | ||
| 45 | await page.goto(PORTAL_URL); | ||
| 46 | const textarea = page.locator('#tokenInput'); | ||
| 47 | const mintUrl = page.locator('#mintUrl'); | ||
| 48 | await expect(textarea).toBeVisible(); | ||
| 49 | await expect(mintUrl).toBeVisible(); | ||
| 50 | const inputBox = await textarea.boundingBox(); | ||
| 51 | const mintBox = await mintUrl.boundingBox(); | ||
| 52 | expect(mintBox.y).toBeGreaterThan(inputBox.y); | ||
| 19 | }); | 53 | }); |
| 20 | 54 | ||
| 21 | test('portal has Cashu token input', async ({ page }) => { | 55 | test('portal has Cashu token input', async ({ page }) => { |
| @@ -52,7 +86,7 @@ test.describe('Captive Portal - Phase 2', () => { | |||
| 52 | expect(resp.status()).toBe(200); | 86 | expect(resp.status()).toBe(200); |
| 53 | const text = await resp.text(); | 87 | const text = await resp.text(); |
| 54 | expect(text).toMatch(/ip=\d+\.\d+\.\d+\.\d+/); | 88 | expect(text).toMatch(/ip=\d+\.\d+\.\d+\.\d+/); |
| 55 | expect(text).toMatch(/mac=[0-9a-f]{2}:/); | 89 | expect(text).toMatch(/mac=(unknown|[0-9a-f]{2}:)/); |
| 56 | }); | 90 | }); |
| 57 | 91 | ||
| 58 | test('/usage returns -1/-1 before payment', async ({ page }) => { | 92 | test('/usage returns -1/-1 before payment', async ({ page }) => { |