diff options
Diffstat (limited to 'main/captive_portal.c')
| -rw-r--r-- | main/captive_portal.c | 104 |
1 files changed, 72 insertions, 32 deletions
diff --git a/main/captive_portal.c b/main/captive_portal.c index 0ae46ab..1f7340e 100644 --- a/main/captive_portal.c +++ b/main/captive_portal.c | |||
| @@ -11,8 +11,9 @@ | |||
| 11 | 11 | ||
| 12 | static const char *TAG = "captive_portal"; | 12 | static const char *TAG = "captive_portal"; |
| 13 | static httpd_handle_t s_server = NULL; | 13 | static httpd_handle_t s_server = NULL; |
| 14 | static char s_ap_ip_str[16] = "10.0.0.1"; | ||
| 14 | 15 | ||
| 15 | static const char PORTAL_HTML[] = \ | 16 | static const char PORTAL_HTML_TEMPLATE[] = \ |
| 16 | "<!DOCTYPE html>" | 17 | "<!DOCTYPE html>" |
| 17 | "<html><head>" | 18 | "<html><head>" |
| 18 | "<meta charset='utf-8'>" | 19 | "<meta charset='utf-8'>" |
| @@ -27,9 +28,15 @@ static const char PORTAL_HTML[] = \ | |||
| 27 | "max-width:400px;width:100%;text-align:center}" | 28 | "max-width:400px;width:100%;text-align:center}" |
| 28 | "h1{font-size:28px;margin-bottom:8px;color:#f7931a}" | 29 | "h1{font-size:28px;margin-bottom:8px;color:#f7931a}" |
| 29 | ".subtitle{color:#888;margin-bottom:24px;font-size:14px}" | 30 | ".subtitle{color:#888;margin-bottom:24px;font-size:14px}" |
| 30 | ".price{background:#252525;border-radius:12px;padding:16px;margin-bottom:24px}" | 31 | ".price{background:#252525;border-radius:12px;padding:16px;margin-bottom:16px}" |
| 31 | ".price-amount{font-size:36px;font-weight:bold;color:#f7931a}" | 32 | ".price-amount{font-size:36px;font-weight:bold;color:#f7931a}" |
| 32 | ".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}" | ||
| 35 | ".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;" | ||
| 37 | "background:#1a1a1a;padding:8px;border-radius:6px;position:relative;cursor:pointer}" | ||
| 38 | ".mint-url:active{opacity:0.7}" | ||
| 39 | ".mint-hint{color:#666;font-size:10px;margin-top:4px}" | ||
| 33 | "#status{margin-top:16px;padding:12px;border-radius:8px;display:none;font-size:14px}" | 40 | "#status{margin-top:16px;padding:12px;border-radius:8px;display:none;font-size:14px}" |
| 34 | "#status.success{display:block;background:#1a472a;color:#4caf50}" | 41 | "#status.success{display:block;background:#1a472a;color:#4caf50}" |
| 35 | "#status.error{display:block;background:#471a1a;color:#f44336}" | 42 | "#status.error{display:block;background:#471a1a;color:#f44336}" |
| @@ -49,6 +56,11 @@ static const char PORTAL_HTML[] = \ | |||
| 49 | "<div class='price-amount' id='price'>Loading...</div>" | 56 | "<div class='price-amount' id='price'>Loading...</div>" |
| 50 | "<div class='price-unit'>sats per minute</div>" | 57 | "<div class='price-unit'>sats per minute</div>" |
| 51 | "</div>" | 58 | "</div>" |
| 59 | "<div class='mints'>" | ||
| 60 | "<div class='mints-title'>SUPPORTED MINTS</div>" | ||
| 61 | "<div class='mint-url' id='mintUrl' onclick='copyMint()'>Loading...</div>" | ||
| 62 | "<div class='mint-hint'>Tap to copy • Mint tokens at this URL before paying</div>" | ||
| 63 | "</div>" | ||
| 52 | "<textarea id='tokenInput' placeholder='Paste your Cashu token here (cashuA...)'></textarea>" | 64 | "<textarea id='tokenInput' placeholder='Paste your Cashu token here (cashuA...)'></textarea>" |
| 53 | "<button class='btn' id='payBtn' onclick='payToken()'>Pay & Connect</button>" | 65 | "<button class='btn' id='payBtn' onclick='payToken()'>Pay & Connect</button>" |
| 54 | "<div id='status'></div>" | 66 | "<div id='status'></div>" |
| @@ -58,16 +70,25 @@ static const char PORTAL_HTML[] = \ | |||
| 58 | "const statusEl=document.getElementById('status');" | 70 | "const statusEl=document.getElementById('status');" |
| 59 | "const payBtn=document.getElementById('payBtn');" | 71 | "const payBtn=document.getElementById('payBtn');" |
| 60 | "const tokenInput=document.getElementById('tokenInput');" | 72 | "const tokenInput=document.getElementById('tokenInput');" |
| 61 | "fetch('http://192.168.4.1:2121/').then(r=>r.json()).then(d=>{" | 73 | "const mintUrlEl=document.getElementById('mintUrl');" |
| 62 | "if(d.tags){const p=d.tags.find(t=>t[0]==='price_per_step');if(p)priceEl.textContent=p[2]||'21';}" | 74 | "fetch('http://__AP_IP__:2121/').then(r=>r.json()).then(d=>{" |
| 63 | "}).catch(()=>{priceEl.textContent='21';});" | 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(){" | ||
| 81 | "const url=mintUrlEl.textContent;" | ||
| 82 | "if(navigator.clipboard){navigator.clipboard.writeText(url);" | ||
| 83 | "mintUrlEl.textContent='Copied!';setTimeout(()=>{mintUrlEl.textContent=url;},1000);}" | ||
| 84 | "}" | ||
| 64 | "function showStatus(msg,type){statusEl.textContent=msg;statusEl.className=type;}" | 85 | "function showStatus(msg,type){statusEl.textContent=msg;statusEl.className=type;}" |
| 65 | "function payToken(){" | 86 | "function payToken(){" |
| 66 | "const token=tokenInput.value.trim();" | 87 | "const token=tokenInput.value.trim();" |
| 67 | "if(!token||!token.startsWith('cashuA')){showStatus('Please paste a valid Cashu token','error');return;}" | 88 | "if(!token||!token.startsWith('cashuA')){showStatus('Please paste a valid Cashu token','error');return;}" |
| 68 | "payBtn.disabled=true;" | 89 | "payBtn.disabled=true;" |
| 69 | "showStatus('Processing payment...','processing');" | 90 | "showStatus('Processing payment...','processing');" |
| 70 | "fetch('http://192.168.4.1:2121/',{method:'POST',body:token}).then(r=>{" | 91 | "fetch('http://__AP_IP__:2121/',{method:'POST',body:token}).then(r=>{" |
| 71 | "if(r.ok)return r.json();" | 92 | "if(r.ok)return r.json();" |
| 72 | "return r.json().then(d=>{throw new Error(d.content||'Payment failed');});" | 93 | "return r.json().then(d=>{throw new Error(d.content||'Payment failed');});" |
| 73 | "}).then(d=>{" | 94 | "}).then(d=>{" |
| @@ -91,28 +112,47 @@ static esp_err_t get_client_ip(httpd_req_t *req, uint32_t *ip_out) | |||
| 91 | return ESP_FAIL; | 112 | return ESP_FAIL; |
| 92 | } | 113 | } |
| 93 | 114 | ||
| 94 | static bool is_captive_detection_uri(const char *uri) | 115 | static esp_err_t portal_handler(httpd_req_t *req); |
| 95 | { | ||
| 96 | return strcmp(uri, "/generate_204") == 0 || | ||
| 97 | strcmp(uri, "/hotspot-detect.html") == 0 || | ||
| 98 | strcmp(uri, "/canonical.html") == 0 || | ||
| 99 | strcmp(uri, "/success.txt") == 0 || | ||
| 100 | strcmp(uri, "/ncsi.txt") == 0 || | ||
| 101 | strcmp(uri, "/connecttest.txt") == 0 || | ||
| 102 | strcmp(uri, "/wpad.dat") == 0 || | ||
| 103 | strcmp(uri, "/redirect") == 0 || | ||
| 104 | strcmp(uri, "/kindle-wifi/wifistub.html") == 0 || | ||
| 105 | strcmp(uri, "/fwlink") == 0 || | ||
| 106 | strcmp(uri, "/connectivity-check.html") == 0 || | ||
| 107 | strcmp(uri, "/generate_204/") == 0 || | ||
| 108 | strcmp(uri, "/hotspot-detect.html/") == 0; | ||
| 109 | } | ||
| 110 | 116 | ||
| 111 | static esp_err_t portal_handler(httpd_req_t *req) | 117 | static esp_err_t portal_handler(httpd_req_t *req) |
| 112 | { | 118 | { |
| 113 | ESP_LOGI(TAG, "GET %s from client", req->uri); | 119 | ESP_LOGI(TAG, "GET %s from client", req->uri); |
| 114 | httpd_resp_set_type(req, "text/html"); | 120 | httpd_resp_set_type(req, "text/html"); |
| 115 | httpd_resp_send(req, PORTAL_HTML, strlen(PORTAL_HTML)); | 121 | |
| 122 | char *html = NULL; | ||
| 123 | const char *tpl = PORTAL_HTML_TEMPLATE; | ||
| 124 | 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 | |||
| 129 | size_t ip_len = strlen(s_ap_ip_str); | ||
| 130 | html = malloc(tpl_len + count * (ip_len > 9 ? ip_len - 9 : 0) + 1); | ||
| 131 | if (!html) { | ||
| 132 | httpd_resp_send_500(req); | ||
| 133 | return ESP_OK; | ||
| 134 | } | ||
| 135 | |||
| 136 | char *out = html; | ||
| 137 | const char *src = tpl; | ||
| 138 | while (*src) { | ||
| 139 | const char *found = strstr(src, "__AP_IP__"); | ||
| 140 | if (found) { | ||
| 141 | memcpy(out, src, found - src); | ||
| 142 | out += found - src; | ||
| 143 | memcpy(out, s_ap_ip_str, ip_len); | ||
| 144 | out += ip_len; | ||
| 145 | src = found + 9; | ||
| 146 | } else { | ||
| 147 | strcpy(out, src); | ||
| 148 | out += strlen(src); | ||
| 149 | break; | ||
| 150 | } | ||
| 151 | } | ||
| 152 | *out = '\0'; | ||
| 153 | |||
| 154 | httpd_resp_send(req, html, out - html); | ||
| 155 | free(html); | ||
| 116 | return ESP_OK; | 156 | return ESP_OK; |
| 117 | } | 157 | } |
| 118 | 158 | ||
| @@ -187,19 +227,18 @@ static esp_err_t reset_auth_handler(httpd_req_t *req) | |||
| 187 | 227 | ||
| 188 | static esp_err_t redirect_to_portal_handler(httpd_req_t *req) | 228 | static esp_err_t redirect_to_portal_handler(httpd_req_t *req) |
| 189 | { | 229 | { |
| 190 | ESP_LOGI(TAG, "Captive detect: GET %s → 302 → http://192.168.4.1/", req->uri); | 230 | ESP_LOGI(TAG, "Captive detect: GET %s → 200 portal HTML", req->uri); |
| 191 | httpd_resp_set_status(req, "302 Found"); | 231 | return portal_handler(req); |
| 192 | httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/"); | ||
| 193 | httpd_resp_set_hdr(req, "Connection", "close"); | ||
| 194 | httpd_resp_send(req, NULL, 0); | ||
| 195 | return ESP_OK; | ||
| 196 | } | 232 | } |
| 197 | 233 | ||
| 198 | static esp_err_t catchall_handler(httpd_req_t *req) | 234 | static esp_err_t catchall_handler(httpd_req_t *req) |
| 199 | { | 235 | { |
| 200 | ESP_LOGI(TAG, "Catchall: GET %s → 302 → http://192.168.4.1/", req->uri); | 236 | ESP_LOGI(TAG, "Catchall: GET %s → 302 → http://%s/", req->uri, s_ap_ip_str); |
| 201 | httpd_resp_set_status(req, "302 Found"); | 237 | httpd_resp_set_status(req, "302 Found"); |
| 202 | httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/"); | 238 | |
| 239 | char location[64]; | ||
| 240 | snprintf(location, sizeof(location), "http://%s/", s_ap_ip_str); | ||
| 241 | httpd_resp_set_hdr(req, "Location", location); | ||
| 203 | httpd_resp_set_hdr(req, "Connection", "close"); | 242 | httpd_resp_set_hdr(req, "Connection", "close"); |
| 204 | httpd_resp_send(req, NULL, 0); | 243 | httpd_resp_send(req, NULL, 0); |
| 205 | return ESP_OK; | 244 | return ESP_OK; |
| @@ -220,9 +259,10 @@ static const httpd_uri_t uri_connecttest = { .uri = "/connecttest.txt", .method | |||
| 220 | static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler }; | 259 | static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler }; |
| 221 | static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler }; | 260 | static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler }; |
| 222 | 261 | ||
| 223 | esp_err_t captive_portal_start(void) | 262 | esp_err_t captive_portal_start(const char *ap_ip_str) |
| 224 | { | 263 | { |
| 225 | if (s_server) return ESP_OK; | 264 | if (s_server) return ESP_OK; |
| 265 | strncpy(s_ap_ip_str, ap_ip_str, sizeof(s_ap_ip_str) - 1); | ||
| 226 | 266 | ||
| 227 | httpd_config_t config = HTTPD_DEFAULT_CONFIG(); | 267 | httpd_config_t config = HTTPD_DEFAULT_CONFIG(); |
| 228 | config.max_uri_handlers = 20; | 268 | config.max_uri_handlers = 20; |