/* * MTG RFID Companion - Registration web server (SoftAP) */ #include #include #include #include "esp_log.h" #include "esp_wifi.h" #include "esp_netif.h" #include "esp_http_server.h" #include "cJSON.h" #include "rfid_manager.h" #include "storage_manager.h" #include "scryfall_client.h" #include "wifi_manager.h" #include "dns_server.h" #include "web_server.h" static const char *TAG = "web"; #define AP_SSID "MTG-Companion" #define AP_PASS "" #define HTTPD_BODY_MAX (2048) static const char SPA_PAGE[] = "" "" "MTG RFID Companion" "

MTG RFID Companion

" "

Manage card tags, decks, and device Wi-Fi connectivity.

" "
" "" "" "
" "" "
" "
" "

Last Scanned Tag

" "

waiting...

" "

Pair With Card Name

" "" "
" "
    " "

    " "
    " "
    " "

    Paired Cards (0)

    " "" "
    " "

    Loading paired cards...

    " "
    " "" "
    " "
    " "

    Active Connection

    " "
    Checking Wi-Fi...
    " "
    " "
    " "
    " "
    " "

    Connect to Wi-Fi

    " "" "
    " "
    " "" "" "
    " "
    " "" "" "
    " "
    " "" "
    " "" "" "
    " "
    " "
    " "" "
    " "

    " "
    " "
    " "

    Saved Networks

    " "" "
    " "

    Loading saved networks...

    " "
    " ""; static httpd_handle_t s_server = NULL; static esp_err_t handle_root(httpd_req_t *req); static esp_err_t handle_last_uid(httpd_req_t *req); static esp_err_t handle_bind(httpd_req_t *req); static esp_err_t handle_cards(httpd_req_t *req); static esp_err_t handle_art(httpd_req_t *req); static esp_err_t handle_unbind(httpd_req_t *req); static esp_err_t handle_captive_portal(httpd_req_t *req); static esp_err_t handle_wifi_status(httpd_req_t *req); static esp_err_t handle_wifi_scan(httpd_req_t *req); static esp_err_t handle_wifi_saved(httpd_req_t *req); static esp_err_t handle_wifi_connect(httpd_req_t *req); static esp_err_t handle_wifi_forget(httpd_req_t *req); static const httpd_uri_t URI_ROOT = { .uri = "/", .method = HTTP_GET, .handler = handle_root, .user_ctx = NULL }; static const httpd_uri_t URI_LAST_UID = { .uri = "/api/last_uid", .method = HTTP_GET, .handler = handle_last_uid, .user_ctx = NULL }; static const httpd_uri_t URI_BIND = { .uri = "/api/bind", .method = HTTP_POST, .handler = handle_bind, .user_ctx = NULL }; static const httpd_uri_t URI_CARDS = { .uri = "/api/cards", .method = HTTP_GET, .handler = handle_cards, .user_ctx = NULL }; static const httpd_uri_t URI_ART = { .uri = "/api/art", .method = HTTP_GET, .handler = handle_art, .user_ctx = NULL }; static const httpd_uri_t URI_UNBIND = { .uri = "/api/unbind", .method = HTTP_POST, .handler = handle_unbind, .user_ctx = NULL }; /* Wi-Fi Management URIs */ static const httpd_uri_t URI_WIFI_STATUS = { .uri = "/api/wifi/status", .method = HTTP_GET, .handler = handle_wifi_status, .user_ctx = NULL }; static const httpd_uri_t URI_WIFI_SCAN = { .uri = "/api/wifi/scan", .method = HTTP_GET, .handler = handle_wifi_scan, .user_ctx = NULL }; static const httpd_uri_t URI_WIFI_SAVED = { .uri = "/api/wifi/saved", .method = HTTP_GET, .handler = handle_wifi_saved, .user_ctx = NULL }; static const httpd_uri_t URI_WIFI_CONNECT = { .uri = "/api/wifi/connect", .method = HTTP_POST, .handler = handle_wifi_connect, .user_ctx = NULL }; static const httpd_uri_t URI_WIFI_FORGET = { .uri = "/api/wifi/forget", .method = HTTP_POST, .handler = handle_wifi_forget, .user_ctx = NULL }; /* Captive Portal Probe URIs */ static const httpd_uri_t URI_CP_APPLE = { .uri = "/hotspot-detect.html", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL }; static const httpd_uri_t URI_CP_204_1 = { .uri = "/generate_204", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL }; static const httpd_uri_t URI_CP_204_2 = { .uri = "/gen_204", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL }; static const httpd_uri_t URI_CP_WIN_1 = { .uri = "/connecttest.txt", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL }; static const httpd_uri_t URI_CP_WIN_2 = { .uri = "/ncsi.txt", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL }; static const httpd_uri_t URI_CP_FF_1 = { .uri = "/canonical.html", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL }; static const httpd_uri_t URI_CP_FF_2 = { .uri = "/success.txt", .method = HTTP_GET, .handler = handle_captive_portal, .user_ctx = NULL }; static void send_json(httpd_req_t *req, int status, const char *body) { httpd_resp_set_status(req, status == 200 ? "200 OK" : "400 Bad Request"); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, body, -1); } static char *url_decode(const char *src) { if (!src) return NULL; char *out = strdup(src); if (!out) return NULL; char *dst = out; for (const char *s = src; *s; s++) { if (*s == '%' && s[1] && s[2]) { char hex[3] = { s[1], s[2], '\0' }; *dst++ = (char)strtol(hex, NULL, 16); s += 2; } else if (*s == '+') { *dst++ = ' '; } else { *dst++ = *s; } } *dst = '\0'; return out; } static char *get_form_param(const char *form, const char *key) { if (!form || !key) return NULL; size_t klen = strlen(key); const char *p = form; while (p && *p) { if (strncmp(p, key, klen) == 0 && p[klen] == '=') { const char *val_start = p + klen + 1; const char *val_end = strchr(val_start, '&'); size_t vlen = val_end ? (size_t)(val_end - val_start) : strlen(val_start); char *raw = malloc(vlen + 1); if (!raw) return NULL; memcpy(raw, val_start, vlen); raw[vlen] = '\0'; char *dec = url_decode(raw); free(raw); return dec; } p = strchr(p, '&'); if (p) p++; } return NULL; } /* ------------------------------------------------------------------ */ /* Endpoints */ /* ------------------------------------------------------------------ */ static esp_err_t handle_root(httpd_req_t *req) { httpd_resp_set_type(req, "text/html"); httpd_resp_send(req, SPA_PAGE, (int)sizeof(SPA_PAGE) - 1); return ESP_OK; } static esp_err_t handle_last_uid(httpd_req_t *req) { char buf[80]; const char *uid = rfid_manager_last_uid(); if (uid == NULL || strlen(uid) == 0) { snprintf(buf, sizeof(buf), "{\"uid\":\"\"}\n"); } else { snprintf(buf, sizeof(buf), "{\"uid\":\"%s\"}\n", uid); } send_json(req, 200, buf); return ESP_OK; } static esp_err_t handle_bind(httpd_req_t *req) { if (req->content_len <= 0 || req->content_len >= HTTPD_BODY_MAX) { send_json(req, 400, "{\"ok\":false,\"error\":\"bad body\"}"); return ESP_OK; } char *form = malloc((size_t)req->content_len + 1); if (form == NULL) { send_json(req, 500, "{\"ok\":false,\"error\":\"no mem\"}"); return ESP_OK; } int got = httpd_req_recv(req, form, (size_t)req->content_len); if (got != req->content_len) { free(form); send_json(req, 400, "{\"ok\":false,\"error\":\"short body\"}"); return ESP_OK; } form[got] = '\0'; char *uid = get_form_param(form, "uid"); char *name = get_form_param(form, "name"); free(form); if (uid) { size_t ulen = strlen(uid); while (ulen > 0 && (uid[ulen - 1] == ' ' || uid[ulen - 1] == '\r' || uid[ulen - 1] == '\n')) { uid[--ulen] = '\0'; } } if (uid == NULL || name == NULL || strlen(uid) == 0 || strlen(name) == 0) { free(uid); free(name); send_json(req, 400, "{\"ok\":false,\"error\":\"missing uid or name\"}"); return ESP_OK; } esp_err_t ret = storage_manager_bind_uid(uid, name); if (ret != ESP_OK) { free(uid); free(name); send_json(req, 400, "{\"ok\":false,\"error\":\"persist failed\"}"); return ESP_OK; } scryfall_fetch_request(name, uid); char ok[192]; snprintf(ok, sizeof(ok), "{\"ok\":true,\"uid\":\"%s\",\"name\":\"%s\"}\n", uid, name); ESP_LOGI(TAG, "paired UID %s -> %s", uid, name); send_json(req, 200, ok); free(uid); free(name); return ESP_OK; } static esp_err_t handle_cards(httpd_req_t *req) { char *text = NULL; size_t len = 0; if (storage_read_text(STORAGE_MAPPINGS, &text, &len) == ESP_OK && text != NULL) { httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, text, (ssize_t)len); free(text); } else { send_json(req, 200, "{}\n"); } return ESP_OK; } static esp_err_t handle_art(httpd_req_t *req) { char query[64] = {0}; char uid[32] = {0}; if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) { httpd_query_key_value(query, "uid", uid, sizeof(uid)); } if (uid[0] == '\0') { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing uid"); return ESP_OK; } char path[64]; snprintf(path, sizeof(path), STORAGE_CARDS_DIR "/%s.jpg", uid); FILE *f = fopen(path, "rb"); if (!f) { httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Art not found"); return ESP_OK; } httpd_resp_set_type(req, "image/jpeg"); httpd_resp_set_hdr(req, "Cache-Control", "public, max-age=86400"); char chunk[1024]; size_t n = 0; while ((n = fread(chunk, 1, sizeof(chunk), f)) > 0) { if (httpd_resp_send_chunk(req, chunk, (ssize_t)n) != ESP_OK) { fclose(f); return ESP_FAIL; } } fclose(f); httpd_resp_send_chunk(req, NULL, 0); return ESP_OK; } static esp_err_t handle_unbind(httpd_req_t *req) { if (req->content_len <= 0 || req->content_len >= HTTPD_BODY_MAX) { send_json(req, 400, "{\"ok\":false,\"error\":\"bad body\"}"); return ESP_OK; } char *form = malloc((size_t)req->content_len + 1); if (form == NULL) { send_json(req, 500, "{\"ok\":false,\"error\":\"no mem\"}"); return ESP_OK; } int got = httpd_req_recv(req, form, (size_t)req->content_len); if (got != req->content_len) { free(form); send_json(req, 400, "{\"ok\":false,\"error\":\"short body\"}"); return ESP_OK; } form[got] = '\0'; char *uid = get_form_param(form, "uid"); free(form); if (uid) { size_t ulen = strlen(uid); while (ulen > 0 && (uid[ulen - 1] == ' ' || uid[ulen - 1] == '\r' || uid[ulen - 1] == '\n')) { uid[--ulen] = '\0'; } } if (uid == NULL || strlen(uid) == 0) { free(uid); send_json(req, 400, "{\"ok\":false,\"error\":\"missing uid\"}"); return ESP_OK; } esp_err_t ret = storage_manager_unbind_uid(uid); if (ret != ESP_OK) { free(uid); send_json(req, 404, "{\"ok\":false,\"error\":\"uid not found\"}"); return ESP_OK; } char ok[128]; snprintf(ok, sizeof(ok), "{\"ok\":true,\"uid\":\"%s\"}\n", uid); ESP_LOGI(TAG, "unbound UID %s via web", uid); send_json(req, 200, ok); free(uid); return ESP_OK; } /* ------------------------------------------------------------------ */ /* Captive Portal & Wi-Fi Management Handlers */ /* ------------------------------------------------------------------ */ static esp_err_t handle_captive_portal(httpd_req_t *req) { httpd_resp_set_status(req, "302 Found"); httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/"); httpd_resp_send(req, NULL, 0); return ESP_OK; } static esp_err_t handle_wifi_status(httpd_req_t *req) { bool conn = wifi_manager_connected(); const char *ssid = wifi_manager_get_connected_ssid(); const char *ip = wifi_manager_get_ip(); int rssi = wifi_manager_get_rssi(); char buf[160]; snprintf(buf, sizeof(buf), "{\"connected\":%s,\"ssid\":\"%s\",\"ip\":\"%s\",\"rssi\":%d}\n", conn ? "true" : "false", ssid ? ssid : "", ip ? ip : "0.0.0.0", rssi); send_json(req, 200, buf); return ESP_OK; } static esp_err_t handle_wifi_scan(httpd_req_t *req) { wifi_ap_record_t *records = NULL; uint16_t count = 0; esp_err_t err = wifi_manager_scan_networks(&records, &count); cJSON *root = cJSON_CreateArray(); if (err == ESP_OK && records != NULL && root != NULL) { for (uint16_t i = 0; i < count; i++) { cJSON *item = cJSON_CreateObject(); if (item) { cJSON_AddStringToObject(item, "ssid", (const char *)records[i].ssid); cJSON_AddNumberToObject(item, "rssi", records[i].rssi); const char *auth_str = "Open"; if (records[i].authmode == WIFI_AUTH_WEP) auth_str = "WEP"; else if (records[i].authmode == WIFI_AUTH_WPA_PSK) auth_str = "WPA"; else if (records[i].authmode == WIFI_AUTH_WPA2_PSK) auth_str = "WPA2"; else if (records[i].authmode == WIFI_AUTH_WPA_WPA2_PSK) auth_str = "WPA/WPA2"; else if (records[i].authmode >= WIFI_AUTH_WPA3_PSK) auth_str = "WPA3"; cJSON_AddStringToObject(item, "auth", auth_str); cJSON_AddItemToArray(root, item); } } } if (records) { free(records); } char *out = root ? cJSON_PrintUnformatted(root) : NULL; if (root) cJSON_Delete(root); if (out) { send_json(req, 200, out); free(out); } else { send_json(req, 200, "[]\n"); } return ESP_OK; } static esp_err_t handle_wifi_saved(httpd_req_t *req) { wifi_profile_t profiles[WIFI_MAX_PROFILES]; int count = wifi_manager_get_saved_profiles(profiles, WIFI_MAX_PROFILES); cJSON *root = cJSON_CreateArray(); if (root) { for (int i = 0; i < count; i++) { cJSON *item = cJSON_CreateObject(); if (item) { cJSON_AddStringToObject(item, "ssid", profiles[i].ssid); cJSON_AddItemToArray(root, item); } } char *out = cJSON_PrintUnformatted(root); cJSON_Delete(root); if (out) { send_json(req, 200, out); free(out); return ESP_OK; } } send_json(req, 200, "[]\n"); return ESP_OK; } typedef struct { char ssid[WIFI_SSID_MAX_LEN + 1]; char pass[65]; } async_wifi_connect_args_t; static void async_wifi_connect_task(void *pvParameters) { async_wifi_connect_args_t *args = (async_wifi_connect_args_t *)pvParameters; if (args) { /* Wait 500ms to allow the HTTP 200 response to be cleanly transmitted and ACKed */ vTaskDelay(pdMS_TO_TICKS(500)); ESP_LOGI(TAG, "Executing deferred connection to '%s'", args->ssid); wifi_manager_connect_new(args->ssid, args->pass, true); free(args); } vTaskDelete(NULL); } static esp_err_t handle_wifi_connect(httpd_req_t *req) { if (req->content_len <= 0 || req->content_len >= HTTPD_BODY_MAX) { send_json(req, 400, "{\"ok\":false,\"error\":\"bad body\"}"); return ESP_OK; } char *form = malloc((size_t)req->content_len + 1); if (form == NULL) { send_json(req, 500, "{\"ok\":false,\"error\":\"no mem\"}"); return ESP_OK; } int got = httpd_req_recv(req, form, (size_t)req->content_len); if (got != req->content_len) { free(form); send_json(req, 400, "{\"ok\":false,\"error\":\"short body\"}"); return ESP_OK; } form[got] = '\0'; char *ssid = get_form_param(form, "ssid"); char *pass = get_form_param(form, "pass"); free(form); if (ssid) { size_t slen = strlen(ssid); while (slen > 0 && (ssid[slen - 1] == ' ' || ssid[slen - 1] == '\r' || ssid[slen - 1] == '\n')) { ssid[--slen] = '\0'; } } if (ssid == NULL || strlen(ssid) == 0) { free(ssid); free(pass); send_json(req, 400, "{\"ok\":false,\"error\":\"missing ssid\"}"); return ESP_OK; } const char *p = pass ? pass : ""; /* Send HTTP 200 OK FIRST before the Wi-Fi stack switches channels or disconnects */ char ok[128]; snprintf(ok, sizeof(ok), "{\"ok\":true,\"ssid\":\"%s\"}\n", ssid); send_json(req, 200, ok); /* Defer the actual connection to a background task so the current socket is not interrupted */ async_wifi_connect_args_t *args = malloc(sizeof(async_wifi_connect_args_t)); if (args) { strncpy(args->ssid, ssid, sizeof(args->ssid) - 1); args->ssid[sizeof(args->ssid) - 1] = '\0'; strncpy(args->pass, p, sizeof(args->pass) - 1); args->pass[sizeof(args->pass) - 1] = '\0'; if (xTaskCreate(async_wifi_connect_task, "wf_conn_async", 4096, args, 5, NULL) != pdPASS) { ESP_LOGE(TAG, "Failed to create async_wifi_connect_task, connecting synchronously"); wifi_manager_connect_new(ssid, p, true); free(args); } } else { wifi_manager_connect_new(ssid, p, true); } free(ssid); free(pass); return ESP_OK; } static esp_err_t handle_wifi_forget(httpd_req_t *req) { if (req->content_len <= 0 || req->content_len >= HTTPD_BODY_MAX) { send_json(req, 400, "{\"ok\":false,\"error\":\"bad body\"}"); return ESP_OK; } char *form = malloc((size_t)req->content_len + 1); if (form == NULL) { send_json(req, 500, "{\"ok\":false,\"error\":\"no mem\"}"); return ESP_OK; } int got = httpd_req_recv(req, form, (size_t)req->content_len); if (got != req->content_len) { free(form); send_json(req, 400, "{\"ok\":false,\"error\":\"short body\"}"); return ESP_OK; } form[got] = '\0'; char *ssid = get_form_param(form, "ssid"); free(form); if (ssid) { size_t slen = strlen(ssid); while (slen > 0 && (ssid[slen - 1] == ' ' || ssid[slen - 1] == '\r' || ssid[slen - 1] == '\n')) { ssid[--slen] = '\0'; } } if (ssid == NULL || strlen(ssid) == 0) { free(ssid); send_json(req, 400, "{\"ok\":false,\"error\":\"missing ssid\"}"); return ESP_OK; } wifi_manager_delete_profile(ssid); char ok[128]; snprintf(ok, sizeof(ok), "{\"ok\":true,\"ssid\":\"%s\"}\n", ssid); send_json(req, 200, ok); free(ssid); return ESP_OK; } /* ------------------------------------------------------------------ */ /* SoftAP + server */ /* ------------------------------------------------------------------ */ esp_err_t web_server_start(void) { if (s_server != NULL) { return ESP_OK; } /* If Wi-Fi is not yet started, configure AP and start it */ wifi_mode_t mode; if (esp_wifi_get_mode(&mode) != ESP_OK) { esp_netif_t *ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); if (ap == NULL) { ap = esp_netif_create_default_wifi_ap(); if (ap != NULL) { esp_netif_set_hostname(ap, "mtg-companion"); } } (void)esp_wifi_set_mode(WIFI_MODE_APSTA); wifi_config_t wifi_cfg = { .ap = { .ssid_len = sizeof(AP_SSID) - 1, .max_connection = 4, .authmode = (AP_PASS[0] == '\0') ? WIFI_AUTH_OPEN : WIFI_AUTH_WPA2_PSK, }, }; strncpy((char *)wifi_cfg.ap.ssid, AP_SSID, sizeof(wifi_cfg.ap.ssid) - 1); if (AP_PASS[0] != '\0') { strncpy((char *)wifi_cfg.ap.password, AP_PASS, sizeof(wifi_cfg.ap.password) - 1); } (void)esp_wifi_set_config(WIFI_IF_AP, &wifi_cfg); (void)esp_wifi_start(); } httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.stack_size = 8 * 1024; config.max_uri_handlers = 24; config.lru_purge_enable = true; config.keep_alive_enable = true; config.max_open_sockets = 4; esp_err_t ret = httpd_start(&s_server, &config); if (ret != ESP_OK) { ESP_LOGE(TAG, "httpd_start failed: %s", esp_err_to_name(ret)); return ret; } /* Core Pairing & Card APIs */ ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_ROOT)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_LAST_UID)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_BIND)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CARDS)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_ART)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_UNBIND)); /* Wi-Fi Management APIs */ ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_STATUS)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_SCAN)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_SAVED)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_CONNECT)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_WIFI_FORGET)); /* Captive Portal OS Probes */ ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_APPLE)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_204_1)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_204_2)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_WIN_1)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_WIN_2)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_FF_1)); ESP_ERROR_CHECK(httpd_register_uri_handler(s_server, &URI_CP_FF_2)); /* Start Captive Portal DNS redirection server */ dns_server_start(); ESP_LOGI(TAG, "Web server ready on all interfaces (Hotspot: 192.168.4.1, Wi-Fi: %s)", wifi_manager_get_ip()); return ESP_OK; } bool web_server_is_running(void) { return s_server != NULL; }