feat: Support for multiple OPDS servers (#1209)
## Summary * Add support for configuring and using multiple OPDS servers, replacing the previous single-server limitation. Closes https://github.com/crosspoint-reader/crosspoint-reader/issues/1178 * New OpdsServerStore singleton (modeled after WifiCredentialStore) that persists up to 8 OPDS servers to /.crosspoint/opds.json with MAC-based password obfuscation. * One-time migration from legacy single-server fields in CrossPointSettings to the new store on first boot. * New OpdsServerListActivity for the device UI — works in two modes: a settings list (add/edit/delete servers) and a picker (select which server to browse). When only one server is configured, the picker is skipped automatically. * Renamed CalibreSettingsActivity → OpdsSettingsActivity for clarity. It now edits individual OpdsServer entries (name, URL, username, password, delete). * OpdsBookBrowserActivity now receives an OpdsServer at construction and uses its credentials for all fetches/downloads, and shows the server name in the header. * HttpDownloader::fetchUrl and downloadToFile accept optional per-call username/password parameters instead of reading from global settings. * REST API endpoints on CrossPointWebServer: GET /api/opds, POST /api/opds, POST /api/opds/delete — passwords are never exposed over the API (only a hasPassword flag), and omitting the password field on update preserves the existing one. * Web UI (SettingsPage.html) with dynamic OPDS server management cards — add, edit, save, and delete servers from the browser. <img width="932" height="906" alt="SCR-20260416-stvu" src="https://github.com/user-attachments/assets/a8f18d84-4204-46a0-bb31-b73d24b3255f" /> ## Additional Context * The OpdsServerStore JSON format and obfuscation scheme are identical to WifiCredentialStore, so the same JsonSettingsIO infrastructure handles both. * The web API uses POST /api/opds/delete instead of DELETE /api/opds because the ESP32 WebServer doesn't support the DELETE method with a request body. * Existing single-server configurations are migrated automatically — no user action required. After migration the legacy CrossPointSettings fields are cleared so it only runs once. * The HttpDownloader changes are backward-compatible: the credential parameters default to empty strings, so existing callers are unaffected. --- ### 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 <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
parent
c5f82709c0
commit
1cf2239742
@@ -11,6 +11,7 @@
|
||||
#include <algorithm>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "SettingsList.h"
|
||||
#include "WebDAVHandler.h"
|
||||
#include "html/FilesPageHtml.generated.h"
|
||||
@@ -160,6 +161,11 @@ void CrossPointWebServer::begin() {
|
||||
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
|
||||
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
|
||||
|
||||
// OPDS server endpoints
|
||||
server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); });
|
||||
server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); });
|
||||
server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); });
|
||||
|
||||
server->onNotFound([this] { handleNotFound(); });
|
||||
LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
|
||||
|
||||
@@ -1244,6 +1250,122 @@ void CrossPointWebServer::handlePostSettings() {
|
||||
server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)");
|
||||
}
|
||||
|
||||
// ---- OPDS Server API ----
|
||||
|
||||
void CrossPointWebServer::handleGetOpdsServers() const {
|
||||
const auto& servers = OPDS_STORE.getServers();
|
||||
|
||||
// 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[512];
|
||||
constexpr size_t outputSize = sizeof(output);
|
||||
JsonDocument doc;
|
||||
|
||||
for (size_t i = 0; i < servers.size(); i++) {
|
||||
doc.clear();
|
||||
doc["index"] = i;
|
||||
doc["name"] = servers[i].name;
|
||||
doc["url"] = servers[i].url;
|
||||
doc["username"] = servers[i].username;
|
||||
// Never expose passwords over the API — only indicate whether one is set
|
||||
doc["hasPassword"] = !servers[i].password.empty();
|
||||
|
||||
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 OPDS servers API (%zu servers)", servers.size());
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handlePostOpdsServer() {
|
||||
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;
|
||||
}
|
||||
|
||||
OpdsServer opdsServer;
|
||||
opdsServer.name = doc["name"] | std::string("");
|
||||
opdsServer.url = doc["url"] | std::string("");
|
||||
opdsServer.username = doc["username"] | std::string("");
|
||||
|
||||
// The password field is optional in the JSON payload. When absent (vs. present but empty),
|
||||
// we preserve the existing password — the web UI omits it when the user hasn't changed it.
|
||||
bool hasPasswordField = doc["password"].is<const char*>() || doc["password"].is<std::string>();
|
||||
std::string password = doc["password"] | std::string("");
|
||||
|
||||
if (doc["index"].is<int>()) {
|
||||
int idx = doc["index"].as<int>();
|
||||
if (idx < 0 || idx >= static_cast<int>(OPDS_STORE.getCount())) {
|
||||
server->send(400, "text/plain", "Invalid server index");
|
||||
return;
|
||||
}
|
||||
// Preserve existing password if not explicitly provided
|
||||
if (!hasPasswordField) {
|
||||
const auto* existing = OPDS_STORE.getServer(static_cast<size_t>(idx));
|
||||
if (existing) password = existing->password;
|
||||
}
|
||||
opdsServer.password = password;
|
||||
OPDS_STORE.updateServer(static_cast<size_t>(idx), opdsServer);
|
||||
LOG_DBG("WEB", "Updated OPDS server at index %d", idx);
|
||||
} else {
|
||||
opdsServer.password = password;
|
||||
if (!OPDS_STORE.addServer(opdsServer)) {
|
||||
server->send(400, "text/plain", "Cannot add server (limit reached)");
|
||||
return;
|
||||
}
|
||||
LOG_DBG("WEB", "Added new OPDS server: %s", opdsServer.name.c_str());
|
||||
}
|
||||
|
||||
server->send(200, "text/plain", "OK");
|
||||
}
|
||||
|
||||
// Uses POST (not HTTP DELETE) because ESP32 WebServer doesn't support DELETE with body.
|
||||
void CrossPointWebServer::handleDeleteOpdsServer() {
|
||||
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<int>()) {
|
||||
server->send(400, "text/plain", "Missing index");
|
||||
return;
|
||||
}
|
||||
|
||||
int idx = doc["index"].as<int>();
|
||||
if (idx < 0 || idx >= static_cast<int>(OPDS_STORE.getCount())) {
|
||||
server->send(400, "text/plain", "Invalid server index");
|
||||
return;
|
||||
}
|
||||
|
||||
OPDS_STORE.removeServer(static_cast<size_t>(idx));
|
||||
LOG_DBG("WEB", "Deleted OPDS server at index %d", idx);
|
||||
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) {
|
||||
|
||||
@@ -107,4 +107,9 @@ class CrossPointWebServer {
|
||||
void handleSettingsPage() const;
|
||||
void handleGetSettings() const;
|
||||
void handlePostSettings();
|
||||
|
||||
// OPDS server handlers
|
||||
void handleGetOpdsServers() const;
|
||||
void handlePostOpdsServer();
|
||||
void handleDeleteOpdsServer();
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "util/UrlUtils.h"
|
||||
|
||||
namespace {
|
||||
@@ -52,8 +51,8 @@ class FileWriteStream final : public Stream {
|
||||
};
|
||||
} // namespace
|
||||
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
|
||||
// Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent, const std::string& username,
|
||||
const std::string& password) {
|
||||
std::unique_ptr<NetworkClient> client;
|
||||
if (UrlUtils::isHttpsUrl(url)) {
|
||||
auto* secureClient = new NetworkClientSecure();
|
||||
@@ -70,9 +69,8 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
|
||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
|
||||
|
||||
// Add Basic HTTP auth if credentials are configured
|
||||
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
|
||||
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
|
||||
if (!username.empty() && !password.empty()) {
|
||||
std::string credentials = username + ":" + password;
|
||||
String encoded = base64::encode(credentials.c_str());
|
||||
http.addHeader("Authorization", "Basic " + encoded);
|
||||
}
|
||||
@@ -92,9 +90,10 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent, const std::string& username,
|
||||
const std::string& password) {
|
||||
StreamString stream;
|
||||
if (!fetchUrl(url, stream)) {
|
||||
if (!fetchUrl(url, stream, username, password)) {
|
||||
return false;
|
||||
}
|
||||
outContent = stream.c_str();
|
||||
@@ -102,8 +101,8 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
|
||||
}
|
||||
|
||||
HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath,
|
||||
ProgressCallback progress) {
|
||||
// Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP
|
||||
ProgressCallback progress, const std::string& username,
|
||||
const std::string& password) {
|
||||
std::unique_ptr<NetworkClient> client;
|
||||
if (UrlUtils::isHttpsUrl(url)) {
|
||||
auto* secureClient = new NetworkClientSecure();
|
||||
@@ -121,9 +120,8 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
|
||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
|
||||
|
||||
// Add Basic HTTP auth if credentials are configured
|
||||
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
|
||||
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
|
||||
if (!username.empty() && !password.empty()) {
|
||||
std::string credentials = username + ":" + password;
|
||||
String encoded = base64::encode(credentials.c_str());
|
||||
http.addHeader("Authorization", "Basic " + encoded);
|
||||
}
|
||||
|
||||
@@ -20,22 +20,18 @@ class HttpDownloader {
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch text content from a URL.
|
||||
* @param url The URL to fetch
|
||||
* @param outContent The fetched content (output)
|
||||
* @return true if fetch succeeded, false on error
|
||||
* Fetch text content from a URL with optional credentials.
|
||||
*/
|
||||
static bool fetchUrl(const std::string& url, std::string& outContent);
|
||||
static bool fetchUrl(const std::string& url, std::string& outContent, const std::string& username = "",
|
||||
const std::string& password = "");
|
||||
|
||||
static bool fetchUrl(const std::string& url, Stream& stream);
|
||||
static bool fetchUrl(const std::string& url, Stream& stream, const std::string& username = "",
|
||||
const std::string& password = "");
|
||||
|
||||
/**
|
||||
* Download a file to the SD card.
|
||||
* @param url The URL to download
|
||||
* @param destPath The destination path on SD card
|
||||
* @param progress Optional progress callback
|
||||
* @return DownloadError indicating success or failure type
|
||||
* Download a file to the SD card with optional credentials.
|
||||
*/
|
||||
static DownloadError downloadToFile(const std::string& url, const std::string& destPath,
|
||||
ProgressCallback progress = nullptr);
|
||||
ProgressCallback progress = nullptr, const std::string& username = "",
|
||||
const std::string& password = "");
|
||||
};
|
||||
|
||||
@@ -207,6 +207,48 @@
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.opds-server {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.opds-server .setting-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.opds-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn-small {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.btn-add {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
.btn-add:hover {
|
||||
background-color: var(--accent-hover-color);
|
||||
}
|
||||
.btn-delete {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
.btn-delete:hover {
|
||||
background-color: #c0392b;
|
||||
}
|
||||
.btn-save-server {
|
||||
background-color: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
.btn-save-server:hover {
|
||||
background-color: #219a52;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
@@ -257,6 +299,8 @@
|
||||
<button class="save-btn" id="saveBtn" onclick="saveSettings()">Save Settings</button>
|
||||
</div>
|
||||
|
||||
<div id="opds-container"></div>
|
||||
|
||||
<div class="card">
|
||||
<p style="text-align: center; color: #95a5a6; margin: 0;">
|
||||
CrossPoint E-Reader • Open Source
|
||||
@@ -435,6 +479,122 @@
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
|
||||
// --- 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;
|
||||
// the "(unchanged)" placeholder indicates an existing password is preserved on save.
|
||||
let opdsServers = [];
|
||||
|
||||
function renderOpdsServer(srv, idx) {
|
||||
const isNew = idx === -1;
|
||||
const id = isNew ? 'new' : idx;
|
||||
return '<div class="opds-server" id="opds-' + id + '">' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">Server Name</span>' +
|
||||
'<span class="setting-control"><input type="text" id="opds-name-' + id + '" value="' + escapeHtml(srv.name || '') + '"></span>' +
|
||||
'</div>' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">URL</span>' +
|
||||
'<span class="setting-control"><input type="text" id="opds-url-' + id + '" value="' + escapeHtml(srv.url || '') + '"></span>' +
|
||||
'</div>' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">Username</span>' +
|
||||
'<span class="setting-control"><input type="text" id="opds-user-' + id + '" value="' + escapeHtml(srv.username || '') + '"></span>' +
|
||||
'</div>' +
|
||||
'<div class="setting-row">' +
|
||||
'<span class="setting-name">Password</span>' +
|
||||
'<span class="setting-control"><input type="password" id="opds-pass-' + id + '" placeholder="' + (srv.hasPassword ? '(unchanged)' : '') + '"></span>' +
|
||||
'</div>' +
|
||||
'<div class="opds-actions">' +
|
||||
'<button class="btn-small btn-save-server" onclick="saveOpdsServer(' + idx + ')">Save</button>' +
|
||||
(isNew ? '' : '<button class="btn-small btn-delete" onclick="deleteOpdsServer(' + idx + ')">Delete</button>') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderOpdsSection() {
|
||||
const container = document.getElementById('opds-container');
|
||||
let html = '<div class="card"><h2>OPDS Servers</h2>';
|
||||
|
||||
if (opdsServers.length === 0) {
|
||||
html += '<p style="color:var(--label-color);text-align:center;">No OPDS servers configured</p>';
|
||||
} else {
|
||||
opdsServers.forEach(function(srv, idx) {
|
||||
html += renderOpdsServer(srv, idx);
|
||||
});
|
||||
}
|
||||
|
||||
html += '<div style="margin-top:12px;text-align:center;">' +
|
||||
'<button class="btn-small btn-add" onclick="addOpdsServer()">+ Add Server</button>' +
|
||||
'</div></div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadOpdsServers() {
|
||||
try {
|
||||
const resp = await fetch('/api/opds');
|
||||
if (!resp.ok) throw new Error('Failed to load');
|
||||
opdsServers = await resp.json();
|
||||
renderOpdsSection();
|
||||
} catch (e) {
|
||||
console.error('OPDS load error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function addOpdsServer() {
|
||||
const container = document.getElementById('opds-container');
|
||||
const card = container.querySelector('.card');
|
||||
const addBtn = card.querySelector('.btn-add').parentElement;
|
||||
// Prevent multiple unsaved new-server forms at once (idx -1 → id "new")
|
||||
if (document.getElementById('opds-new')) return;
|
||||
addBtn.insertAdjacentHTML('beforebegin', renderOpdsServer({name:'',url:'',username:'',hasPassword:false}, -1));
|
||||
}
|
||||
|
||||
async function saveOpdsServer(idx) {
|
||||
const id = idx === -1 ? 'new' : idx;
|
||||
const data = {
|
||||
name: document.getElementById('opds-name-' + id).value,
|
||||
url: document.getElementById('opds-url-' + id).value,
|
||||
username: document.getElementById('opds-user-' + id).value,
|
||||
};
|
||||
// Only include password in payload when the user actually typed something;
|
||||
// omitting it tells the server to keep the existing password.
|
||||
const pass = document.getElementById('opds-pass-' + id).value;
|
||||
if (pass) data.password = pass;
|
||||
if (idx >= 0) data.index = idx;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/opds', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
showMessage('OPDS server saved!', false);
|
||||
await loadOpdsServers();
|
||||
} catch (e) {
|
||||
showMessage('Error: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOpdsServer(idx) {
|
||||
if (!confirm('Delete this OPDS server?')) return;
|
||||
try {
|
||||
const resp = await fetch('/api/opds/delete', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({index: idx})
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
showMessage('OPDS server deleted', false);
|
||||
await loadOpdsServers();
|
||||
} catch (e) {
|
||||
showMessage('Error: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
loadOpdsServers();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user