From e8d7153d7fad86a584f85c4eafe6e00de9d170e7 Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Fri, 8 May 2026 07:29:48 +0500 Subject: [PATCH] feat: edit wifi networks in webui (#1743) ## Summary **What is the goal of this PR?** Add Wi-Fi network management to the Web UI settings page, similar to existing OPDS server management, so users can view, add, edit, and delete saved Wi-Fi credentials from the browser. Closes https://github.com/crosspoint-reader/crosspoint-reader/issues/1544 and https://github.com/crosspoint-reader/crosspoint-reader/discussions/607 **What changes are included?** - Added Wi-Fi API endpoints: - GET /api/wifi Listing saved networks (without exposing plaintext passwords) - POST /api/wifi Creating/updating networks - POST /api/wifi/delete Deleting networks by index - Added a new Wi-Fi Networks management section web UI settings page image --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< YES >**_ --------- Co-authored-by: Copilot --- USER_GUIDE.md | 22 ++++- src/network/CrossPointWebServer.cpp | 140 ++++++++++++++++++++++++++++ src/network/CrossPointWebServer.h | 5 + src/network/html/SettingsPage.html | 115 +++++++++++++++++++++++ 4 files changed, 280 insertions(+), 2 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 8cc3ad00..827e85ea 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -21,7 +21,8 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control - [3.6.3 Controls](#363-controls) - [3.6.4 System](#364-system) - [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) - - [3.6.6 KOReader Sync Quick Setup](#366-koreader-sync-quick-setup) + - [3.6.6 Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds) + - [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup) - [3.7 Sleep Screen](#37-sleep-screen) - [4. Reading Mode](#4-reading-mode) - [Page Turning](#page-turning) @@ -223,7 +224,24 @@ You can also manage OPDS servers from the web interface while in File Transfer m 2. Open `http:///settings`. 3. Use the **OPDS Servers** card to add, edit, or delete entries. -#### 3.6.6 KOReader Sync Quick Setup +For web-based WiFi network management, see [Web Settings (WiFi + OPDS)](#366-web-settings-wifi--opds). + +#### 3.6.6 Web Settings (WiFi + OPDS) + +While in **File Transfer** mode, the web settings page includes management cards for both **WiFi Networks** and **OPDS Servers**. + +1. On device: open **File Transfer** and connect to WiFi. +1. In a browser, open `http:///settings` or `http://crosspoint.local`. +1. In **WiFi Networks**, add, edit, or delete saved network entries (SSID + optional password). +1. In **OPDS Servers**, add, edit, or delete OPDS catalogs. + +Behavior notes: + +- Passwords are never shown back in the web UI after saving. +- Leaving Password blank while editing keeps the existing saved password unchanged. +- The web UI can save hidden-network SSIDs, but connecting to hidden networks still depends on device-side WiFi connection flow. + +#### 3.6.7 KOReader Sync Quick Setup CrossPoint can sync reading progress with KOReader-compatible sync servers. It also interoperates with KOReader apps/devices when they use the same server and credentials. diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 713981ea..7c795503 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -14,6 +14,7 @@ #include "OpdsServerStore.h" #include "SettingsList.h" #include "WebDAVHandler.h" +#include "WifiCredentialStore.h" #include "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" #include "html/SettingsPageHtml.generated.h" @@ -168,6 +169,11 @@ void CrossPointWebServer::begin() { server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); }); server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); }); + // Wi-Fi credential endpoints + server->on("/api/wifi", HTTP_GET, [this] { handleGetWifiNetworks(); }); + server->on("/api/wifi", HTTP_POST, [this] { handlePostWifiNetwork(); }); + server->on("/api/wifi/delete", HTTP_POST, [this] { handleDeleteWifiNetwork(); }); + server->onNotFound([this] { handleNotFound(); }); LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap()); @@ -1368,6 +1374,140 @@ void CrossPointWebServer::handleDeleteOpdsServer() { server->send(200, "text/plain", "OK"); } +// ---- Wi-Fi Credentials API ---- + +void CrossPointWebServer::handleGetWifiNetworks() const { + const auto& credentials = WIFI_STORE.getCredentials(); + const std::string& lastConnectedSsid = WIFI_STORE.getLastConnectedSsid(); + + // Stream JSON array incrementally to avoid allocating the full response in memory + server->setContentLength(CONTENT_LENGTH_UNKNOWN); + server->send(200, "application/json", ""); + server->sendContent("["); + + char output[320]; + constexpr size_t outputSize = sizeof(output); + JsonDocument doc; + + for (size_t i = 0; i < credentials.size(); i++) { + doc.clear(); + doc["index"] = i; + doc["ssid"] = credentials[i].ssid; + // Never expose Wi-Fi passwords over the API — only indicate whether one is set + doc["hasPassword"] = !credentials[i].password.empty(); + doc["isLastConnected"] = credentials[i].ssid == lastConnectedSsid; + + const size_t written = serializeJson(doc, output, outputSize); + if (written >= outputSize) continue; + + if (i > 0) server->sendContent(","); + server->sendContent(output); + } + + server->sendContent("]"); + server->sendContent(""); + LOG_DBG("WEB", "Served Wi-Fi credentials API (%zu network(s))", credentials.size()); +} + +void CrossPointWebServer::handlePostWifiNetwork() { + if (!server->hasArg("plain")) { + server->send(400, "text/plain", "Missing JSON body"); + return; + } + + const String body = server->arg("plain"); + JsonDocument doc; + const DeserializationError err = deserializeJson(doc, body); + if (err) { + server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str()); + return; + } + + std::string ssid = doc["ssid"] | std::string(""); + if (ssid.empty()) { + server->send(400, "text/plain", "SSID is required"); + return; + } + + // The password field is optional in the JSON payload. When absent (vs. present but empty), + // preserve the existing password for updates. Empty passwords are valid for open networks. + bool hasPasswordField = doc["password"].is() || doc["password"].is(); + std::string password = doc["password"] | std::string(""); + + if (doc["index"].is()) { + int idx = doc["index"].as(); + const auto& credentials = WIFI_STORE.getCredentials(); + if (idx < 0 || idx >= static_cast(credentials.size())) { + server->send(400, "text/plain", "Invalid network index"); + return; + } + + const std::string oldSsid = credentials[static_cast(idx)].ssid; + if (!hasPasswordField) { + password = credentials[static_cast(idx)].password; + } + + bool ok = true; + if (oldSsid != ssid) { + ok = WIFI_STORE.removeCredential(oldSsid) && WIFI_STORE.addCredential(ssid, password); + } else { + ok = WIFI_STORE.addCredential(ssid, password); + } + + if (!ok) { + server->send(400, "text/plain", "Failed to update Wi-Fi network"); + return; + } + + LOG_DBG("WEB", "Updated Wi-Fi network at index %d (SSID: %s)", idx, ssid.c_str()); + } else { + if (!WIFI_STORE.addCredential(ssid, password)) { + server->send(400, "text/plain", "Cannot add network (limit reached)"); + return; + } + LOG_DBG("WEB", "Added Wi-Fi network: %s", ssid.c_str()); + } + + server->send(200, "text/plain", "OK"); +} + +// Uses POST (not HTTP DELETE) because ESP32 WebServer doesn't support DELETE with body. +void CrossPointWebServer::handleDeleteWifiNetwork() { + if (!server->hasArg("plain")) { + server->send(400, "text/plain", "Missing JSON body"); + return; + } + + const String body = server->arg("plain"); + JsonDocument doc; + const DeserializationError err = deserializeJson(doc, body); + if (err) { + server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str()); + return; + } + + if (!doc["index"].is()) { + server->send(400, "text/plain", "Missing index"); + return; + } + + int idx = doc["index"].as(); + const auto& credentials = WIFI_STORE.getCredentials(); + if (idx < 0 || idx >= static_cast(credentials.size())) { + server->send(400, "text/plain", "Invalid network index"); + return; + } + + const std::string ssid = credentials[static_cast(idx)].ssid; + if (!WIFI_STORE.removeCredential(ssid)) { + server->send(400, "text/plain", "Failed to delete Wi-Fi network"); + return; + } + + LOG_DBG("WEB", "Deleted Wi-Fi network at index %d (SSID: %s)", idx, ssid.c_str()); + server->send(200, "text/plain", "OK"); +} + // WebSocket callback trampoline void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { if (wsInstance) { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index aac60e19..6850b687 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -112,4 +112,9 @@ class CrossPointWebServer { void handleGetOpdsServers() const; void handlePostOpdsServer(); void handleDeleteOpdsServer(); + + // Wi-Fi credential handlers + void handleGetWifiNetworks() const; + void handlePostWifiNetwork(); + void handleDeleteWifiNetwork(); }; diff --git a/src/network/html/SettingsPage.html b/src/network/html/SettingsPage.html index 9b9ac8ec..47d846f2 100644 --- a/src/network/html/SettingsPage.html +++ b/src/network/html/SettingsPage.html @@ -299,6 +299,7 @@ +
@@ -480,6 +481,119 @@ loadSettings(); + // --- Wi-Fi Network Management --- + // Renders an editable list of saved Wi-Fi networks using /api/wifi endpoints. + // Password fields are never pre-filled; when left blank during edit, existing + // passwords remain unchanged server-side. + let wifiNetworks = []; + + function renderWifiNetwork(net, idx) { + const isNew = idx === -1; + const id = isNew ? 'new' : idx; + const lastConnected = net.isLastConnected + ? '
Last connected network
' + : ''; + + return '
' + + '
' + + 'SSID' + + '' + + '
' + + '
' + + 'Password' + + '' + + '
' + + lastConnected + + '
' + + '' + + (isNew ? '' : '') + + '
' + + '
'; + } + + function renderWifiSection() { + const container = document.getElementById('wifi-container'); + let html = '

Wi-Fi Networks

'; + + if (wifiNetworks.length === 0) { + html += '

No Wi-Fi networks saved

'; + } else { + wifiNetworks.forEach(function(net, idx) { + html += renderWifiNetwork(net, idx); + }); + } + + html += '
' + + '' + + '
'; + container.innerHTML = html; + } + + async function loadWifiNetworks() { + try { + const resp = await fetch('/api/wifi'); + if (!resp.ok) throw new Error('Failed to load'); + wifiNetworks = await resp.json(); + renderWifiSection(); + } catch (e) { + console.error('Wi-Fi load error:', e); + } + } + + function addWifiNetwork() { + const container = document.getElementById('wifi-container'); + const card = container.querySelector('.card'); + const addBtn = card.querySelector('.btn-add').parentElement; + // Prevent multiple unsaved new-network forms at once (idx -1 -> id "new") + if (document.getElementById('wifi-new')) return; + addBtn.insertAdjacentHTML('beforebegin', renderWifiNetwork({ssid:'',hasPassword:false,isLastConnected:false}, -1)); + } + + async function saveWifiNetwork(idx) { + const id = idx === -1 ? 'new' : idx; + const ssid = document.getElementById('wifi-ssid-' + id).value.trim(); + if (!ssid) { + showMessage('SSID is required.', true); + return; + } + + const data = { ssid: ssid }; + // Only include password when the user actually typed something; omitting it + // tells the server to preserve an existing password. + const pass = document.getElementById('wifi-pass-' + id).value; + if (pass) data.password = pass; + if (idx >= 0) data.index = idx; + + try { + const resp = await fetch('/api/wifi', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(data) + }); + if (!resp.ok) throw new Error(await resp.text()); + showMessage('Wi-Fi network saved!', false); + await loadWifiNetworks(); + } catch (e) { + showMessage('Error: ' + e.message, true); + } + } + + async function deleteWifiNetwork(idx) { + if (!confirm('Delete this Wi-Fi network?')) return; + try { + const resp = await fetch('/api/wifi/delete', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({index: idx}) + }); + if (!resp.ok) throw new Error(await resp.text()); + showMessage('Wi-Fi network deleted', false); + await loadWifiNetworks(); + } catch (e) { + showMessage('Error: ' + e.message, true); + } + } + // --- OPDS Server Management --- // Dynamically renders an editable list of OPDS servers, communicating with the // /api/opds REST endpoints. Password fields are never pre-filled for security; @@ -594,6 +708,7 @@ } } + loadWifiNetworks(); loadOpdsServers();