From da3abbacd5771630df45756ee475e56ef82c15c5 Mon Sep 17 00:00:00 2001 From: Katie Paxton-Fear <36405442+InsiderPhD@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:16:03 +0000 Subject: [PATCH] Added support for authentication in Calibre --- src/CrossPointSettings.cpp | 20 ++++- src/CrossPointSettings.h | 2 + src/SettingsList.h | 4 + .../settings/CalibreSettingsActivity.cpp | 50 ++++++++++++- src/util/UrlUtils.cpp | 75 +++++++++++++++++++ src/util/UrlUtils.h | 13 ++++ 6 files changed, 159 insertions(+), 5 deletions(-) diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 1ca9ea74..48c78795 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -12,9 +12,9 @@ CrossPointSettings CrossPointSettings::instance; namespace { -constexpr uint8_t SETTINGS_FILE_VERSION = 1; +constexpr uint8_t SETTINGS_FILE_VERSION = 2; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 17; +constexpr uint8_t SETTINGS_COUNT = 19; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -47,6 +47,8 @@ bool CrossPointSettings::saveToFile() const { serialization::writeString(outputFile, std::string(opdsServerUrl)); serialization::writePod(outputFile, textAntiAliasing); serialization::writePod(outputFile, hideBatteryPercentage); + serialization::writeString(outputFile, std::string(calibreUsername)); + serialization::writeString(outputFile, std::string(calibrePassword)); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -113,6 +115,20 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, hideBatteryPercentage); if (++settingsRead >= fileSettingsCount) break; + { + std::string usernameStr; + serialization::readString(inputFile, usernameStr); + strncpy(calibreUsername, usernameStr.c_str(), sizeof(calibreUsername) - 1); + calibreUsername[sizeof(calibreUsername) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; + { + std::string passwordStr; + serialization::readString(inputFile, passwordStr); + strncpy(calibrePassword, passwordStr.c_str(), sizeof(calibrePassword) - 1); + calibrePassword[sizeof(calibrePassword) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index d5f91039..ee7a7cf7 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -88,6 +88,8 @@ class CrossPointSettings { uint8_t screenMargin = 5; // OPDS browser settings char opdsServerUrl[128] = ""; + char calibreUsername[64] = ""; + char calibrePassword[64] = ""; // Hide battery percentage uint8_t hideBatteryPercentage = HIDE_NEVER; diff --git a/src/SettingsList.h b/src/SettingsList.h index a6b15fe8..346932ba 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -41,6 +41,10 @@ inline std::vector getSettingsList() { {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), SettingInfo::String("opdsServerUrl", "Calibre Web URL", SETTINGS.opdsServerUrl, sizeof(SETTINGS.opdsServerUrl) - 1), + SettingInfo::String("calibreUsername", "Calibre Username", SETTINGS.calibreUsername, + sizeof(SETTINGS.calibreUsername) - 1), + SettingInfo::String("calibrePassword", "Calibre Password", SETTINGS.calibrePassword, + sizeof(SETTINGS.calibrePassword) - 1), SettingInfo::Action("Calibre Settings"), SettingInfo::Action("Check for updates"), }; diff --git a/src/activities/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp index 4f614ffc..3c3ee083 100644 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ b/src/activities/settings/CalibreSettingsActivity.cpp @@ -13,8 +13,8 @@ #include "fontIds.h" namespace { -constexpr int MENU_ITEMS = 2; -const char* menuNames[MENU_ITEMS] = {"Calibre Web URL", "Connect as Wireless Device"}; +constexpr int MENU_ITEMS = 4; +const char* menuNames[MENU_ITEMS] = {"Calibre Web URL", "Username", "Password", "Connect as Wireless Device"}; } // namespace void CalibreSettingsActivity::taskTrampoline(void* param) { @@ -98,6 +98,42 @@ void CalibreSettingsActivity::handleSelection() { updateRequired = true; })); } else if (selectedIndex == 1) { + // Username + exitActivity(); + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Username", SETTINGS.calibreUsername, 10, + 63, // maxLength + false, // not password + [this](const std::string& username) { + strncpy(SETTINGS.calibreUsername, username.c_str(), sizeof(SETTINGS.calibreUsername) - 1); + SETTINGS.calibreUsername[sizeof(SETTINGS.calibreUsername) - 1] = '\0'; + SETTINGS.saveToFile(); + exitActivity(); + updateRequired = true; + }, + [this]() { + exitActivity(); + updateRequired = true; + })); + } else if (selectedIndex == 2) { + // Password + exitActivity(); + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Password", SETTINGS.calibrePassword, 10, + 63, // maxLength + true, // is password + [this](const std::string& password) { + strncpy(SETTINGS.calibrePassword, password.c_str(), sizeof(SETTINGS.calibrePassword) - 1); + SETTINGS.calibrePassword[sizeof(SETTINGS.calibrePassword) - 1] = '\0'; + SETTINGS.saveToFile(); + exitActivity(); + updateRequired = true; + }, + [this]() { + exitActivity(); + updateRequired = true; + })); + } else if (selectedIndex == 3) { // Wireless Device - launch the activity (handles WiFi connection internally) exitActivity(); if (WiFi.status() != WL_CONNECTED) { @@ -153,11 +189,19 @@ void CalibreSettingsActivity::render() { renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected); - // Draw status for URL setting + // Draw status for URL, username, and password settings if (i == 0) { const char* status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]"; const auto width = renderer.getTextWidth(UI_10_FONT_ID, status); renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected); + } else if (i == 1) { + const char* status = (strlen(SETTINGS.calibreUsername) > 0) ? "[Set]" : "[Not Set]"; + const auto width = renderer.getTextWidth(UI_10_FONT_ID, status); + renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected); + } else if (i == 2) { + const char* status = (strlen(SETTINGS.calibrePassword) > 0) ? "[Set]" : "[Not Set]"; + const auto width = renderer.getTextWidth(UI_10_FONT_ID, status); + renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected); } } diff --git a/src/util/UrlUtils.cpp b/src/util/UrlUtils.cpp index 0eeeae3a..9ba4ce5d 100644 --- a/src/util/UrlUtils.cpp +++ b/src/util/UrlUtils.cpp @@ -1,5 +1,8 @@ #include "UrlUtils.h" +#include +#include + namespace UrlUtils { std::string ensureProtocol(const std::string& url) { @@ -38,4 +41,76 @@ std::string buildUrl(const std::string& serverUrl, const std::string& path) { return urlWithProtocol + "/" + path; } +std::string urlEncode(const std::string& value) { + std::ostringstream escaped; + escaped.fill('0'); + escaped << std::hex; + + for (char c : value) { + // Keep alphanumeric and other safe characters intact + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + escaped << c; + } else { + // Encode special characters + escaped << std::uppercase; + escaped << '%' << std::setw(2) << int((unsigned char)c); + escaped << std::nouppercase; + } + } + + return escaped.str(); +} + +std::string buildUrlWithAuth(const std::string& serverUrl, const std::string& path, + const std::string& username, const std::string& password) { + // If no credentials, use regular buildUrl + if (username.empty() && password.empty()) { + return buildUrl(serverUrl, path); + } + + std::string urlWithProtocol = ensureProtocol(serverUrl); + + // Find protocol end + const size_t protocolEnd = urlWithProtocol.find("://"); + if (protocolEnd == std::string::npos) { + return buildUrl(serverUrl, path); // Fallback if no protocol + } + + // Extract protocol and host parts + std::string protocol = urlWithProtocol.substr(0, protocolEnd + 3); // Include :// + std::string hostAndPath = urlWithProtocol.substr(protocolEnd + 3); + + // Check if auth already exists in URL + const size_t atPos = hostAndPath.find('@'); + if (atPos != std::string::npos) { + // Auth already in URL, remove it + hostAndPath = hostAndPath.substr(atPos + 1); + } + + // Build auth string with URL encoding + std::string auth; + if (!username.empty() || !password.empty()) { + auth = urlEncode(username) + ":" + urlEncode(password) + "@"; + } + + // Reconstruct URL with auth + std::string authenticatedUrl = protocol + auth + hostAndPath; + + // Now apply path logic + if (path.empty()) { + return authenticatedUrl; + } + if (path[0] == '/') { + // Absolute path - extract just protocol + auth + host + const size_t firstSlash = hostAndPath.find('/'); + std::string hostOnly = (firstSlash == std::string::npos) ? hostAndPath : hostAndPath.substr(0, firstSlash); + return protocol + auth + hostOnly + path; + } + // Relative path + if (authenticatedUrl.back() == '/') { + return authenticatedUrl + path; + } + return authenticatedUrl + "/" + path; +} + } // namespace UrlUtils diff --git a/src/util/UrlUtils.h b/src/util/UrlUtils.h index d88ca13c..961d79bc 100644 --- a/src/util/UrlUtils.h +++ b/src/util/UrlUtils.h @@ -20,4 +20,17 @@ std::string extractHost(const std::string& url); */ std::string buildUrl(const std::string& serverUrl, const std::string& path); +/** + * URL encode a string (percent encoding for special characters) + */ +std::string urlEncode(const std::string& value); + +/** + * Build URL with basic authentication embedded. + * If username and password are provided, adds them to the URL. + * Example: https://username:password@example.com/path + */ +std::string buildUrlWithAuth(const std::string& serverUrl, const std::string& path, + const std::string& username, const std::string& password); + } // namespace UrlUtils