From c2c1badc7e04f5ac5ea64c1dc7396a675283b353 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Wed, 8 Jul 2026 12:44:52 +0300 Subject: [PATCH] chore: Refactor stores to use PersistableStore CRTP template (#2464) --- lib/KOReaderSync/KOReaderCredentialStore.cpp | 113 +++------------ lib/KOReaderSync/KOReaderCredentialStore.h | 23 ++- lib/KOReaderSync/KOReaderJsonIO.cpp | 51 ------- lib/KOReaderSync/KOReaderJsonIO.h | 8 -- lib/Serialization/PersistableStore.cpp | 45 ++++++ lib/Serialization/PersistableStore.h | 82 +++++++++++ src/CrossPointSettings.cpp | 23 +-- src/CrossPointSettings.h | 4 - src/JsonSettingsIO.cpp | 144 ------------------- src/JsonSettingsIO.h | 12 -- src/OpdsServerStore.cpp | 98 +++++-------- src/OpdsServerStore.h | 31 ++-- src/RecentBooksStore.cpp | 136 ++++-------------- src/RecentBooksStore.h | 32 ++--- src/WifiCredentialStore.cpp | 112 ++++----------- src/WifiCredentialStore.h | 30 ++-- 16 files changed, 277 insertions(+), 667 deletions(-) delete mode 100644 lib/KOReaderSync/KOReaderJsonIO.cpp delete mode 100644 lib/KOReaderSync/KOReaderJsonIO.h create mode 100644 lib/Serialization/PersistableStore.cpp create mode 100644 lib/Serialization/PersistableStore.h diff --git a/lib/KOReaderSync/KOReaderCredentialStore.cpp b/lib/KOReaderSync/KOReaderCredentialStore.cpp index 3b96eb50..f418f602 100644 --- a/lib/KOReaderSync/KOReaderCredentialStore.cpp +++ b/lib/KOReaderSync/KOReaderCredentialStore.cpp @@ -1,118 +1,43 @@ #include "KOReaderCredentialStore.h" -#include #include #include #include -#include - -#include "KOReaderJsonIO.h" - -// Initialize the static instance -KOReaderCredentialStore KOReaderCredentialStore::instance; namespace { -// File format version (for binary migration) -constexpr uint8_t KOREADER_FILE_VERSION = 1; - -// File paths -constexpr char KOREADER_FILE_BIN[] = "/.crosspoint/koreader.bin"; -constexpr char KOREADER_FILE_JSON[] = "/.crosspoint/koreader.json"; -constexpr char KOREADER_FILE_BAK[] = "/.crosspoint/koreader.bin.bak"; - // Default sync server URL constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443"; - -// Legacy obfuscation key - "KOReader" in ASCII (only used for binary migration) -constexpr uint8_t LEGACY_OBFUSCATION_KEY[] = {0x4B, 0x4F, 0x52, 0x65, 0x61, 0x64, 0x65, 0x72}; -constexpr size_t LEGACY_KEY_LENGTH = sizeof(LEGACY_OBFUSCATION_KEY); - -void legacyDeobfuscate(std::string& data) { - for (size_t i = 0; i < data.size(); i++) { - data[i] ^= LEGACY_OBFUSCATION_KEY[i % LEGACY_KEY_LENGTH]; - } -} } // namespace -bool KOReaderCredentialStore::saveToFile() const { - Storage.mkdir("/.crosspoint"); - return KOReaderJsonIO::save(*this, KOREADER_FILE_JSON); +void KOReaderCredentialStore::toJson(JsonDocument& doc) const { + doc["username"] = getUsername(); + doc["password_obf"] = obfuscation::obfuscateToBase64(getPassword()); + doc["serverUrl"] = getServerUrl(); + doc["matchMethod"] = static_cast(getMatchMethod()); } -bool KOReaderCredentialStore::loadFromFile() { - // Try JSON first - if (Storage.exists(KOREADER_FILE_JSON)) { - String json = Storage.readFile(KOREADER_FILE_JSON); - if (!json.isEmpty()) { - bool resave = false; - bool result = KOReaderJsonIO::load(*this, json.c_str(), &resave); - if (result && resave) { - saveToFile(); - LOG_DBG("KRS", "Resaved KOReader credentials to update format"); - } - return result; - } - } +bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) { + std::string user = doc["username"] | ""; - // Fall back to binary migration - if (Storage.exists(KOREADER_FILE_BIN)) { - if (loadFromBinaryFile()) { - if (saveToFile()) { - Storage.rename(KOREADER_FILE_BIN, KOREADER_FILE_BAK); - LOG_DBG("KRS", "Migrated koreader.bin to koreader.json"); - return true; - } else { - LOG_ERR("KRS", "Failed to save KOReader credentials during migration"); - return false; - } - } - } + bool needsResave = false; + std::string pass = extractPassword(doc, needsResave); - LOG_DBG("KRS", "No credentials file found"); - return false; -} + setCredentials(user, pass); + setServerUrl(doc["serverUrl"] | ""); -bool KOReaderCredentialStore::loadFromBinaryFile() { - HalFile file; - if (!Storage.openFileForRead("KRS", KOREADER_FILE_BIN, file)) { - return false; - } - - uint8_t version; - serialization::readPod(file, version); - if (version != KOREADER_FILE_VERSION) { - LOG_DBG("KRS", "Unknown file version: %u", version); - return false; - } - - if (file.available()) { - serialization::readString(file, username); + uint8_t method = doc["matchMethod"] | (uint8_t)0; + if (method <= static_cast(DocumentMatchMethod::BINARY)) { + setMatchMethod(static_cast(method)); } else { - username.clear(); + LOG_DBG("KRS", "Invalid matchMethod %u in JSON, resetting to FILENAME", method); + setMatchMethod(DocumentMatchMethod::FILENAME); } - if (file.available()) { - serialization::readString(file, password); - legacyDeobfuscate(password); - } else { - password.clear(); + if (needsResave) { + LOG_DBG("KRS", "Resaved KOReader credentials to update format"); + saveToFile(); } - if (file.available()) { - serialization::readString(file, serverUrl); - } else { - serverUrl.clear(); - } - - if (file.available()) { - uint8_t method; - serialization::readPod(file, method); - matchMethod = static_cast(method); - } else { - matchMethod = DocumentMatchMethod::FILENAME; - } - - LOG_DBG("KRS", "Loaded KOReader credentials from binary for user: %s", username.c_str()); return true; } diff --git a/lib/KOReaderSync/KOReaderCredentialStore.h b/lib/KOReaderSync/KOReaderCredentialStore.h index c2ea515d..1ac437a1 100644 --- a/lib/KOReaderSync/KOReaderCredentialStore.h +++ b/lib/KOReaderSync/KOReaderCredentialStore.h @@ -1,4 +1,7 @@ #pragma once +#include +#include + #include #include @@ -14,9 +17,9 @@ enum class DocumentMatchMethod : uint8_t { * and base64-encoded before writing to JSON (not cryptographically secure, * but prevents casual reading and ties credentials to the specific device). */ -class KOReaderCredentialStore { + +class KOReaderCredentialStore : public PersistableStore { private: - static KOReaderCredentialStore instance; std::string username; std::string password; std::string serverUrl; // Custom sync server URL (empty = default) @@ -24,20 +27,14 @@ class KOReaderCredentialStore { // Private constructor for singleton KOReaderCredentialStore() = default; + ~KOReaderCredentialStore() = default; - bool loadFromBinaryFile(); + friend class PersistableStore; public: - // Delete copy constructor and assignment - KOReaderCredentialStore(const KOReaderCredentialStore&) = delete; - KOReaderCredentialStore& operator=(const KOReaderCredentialStore&) = delete; - - // Get singleton instance - static KOReaderCredentialStore& getInstance() { return instance; } - - // Save/load from SD card - bool saveToFile() const; - bool loadFromFile(); + static const char* getFilePath() { return "/.crosspoint/koreader.json"; } + void toJson(JsonDocument& doc) const; + bool fromJson(JsonVariantConst doc); // Credential management void setCredentials(const std::string& user, const std::string& pass); diff --git a/lib/KOReaderSync/KOReaderJsonIO.cpp b/lib/KOReaderSync/KOReaderJsonIO.cpp deleted file mode 100644 index 05bfc119..00000000 --- a/lib/KOReaderSync/KOReaderJsonIO.cpp +++ /dev/null @@ -1,51 +0,0 @@ -#include "KOReaderJsonIO.h" - -#include -#include -#include -#include - -#include "KOReaderCredentialStore.h" - -namespace KOReaderJsonIO { - -bool save(const KOReaderCredentialStore& store, const char* path) { - JsonDocument doc; - doc["username"] = store.getUsername(); - doc["password_obf"] = obfuscation::obfuscateToBase64(store.getPassword()); - doc["serverUrl"] = store.getServerUrl(); - doc["matchMethod"] = static_cast(store.getMatchMethod()); - - String json; - serializeJson(doc, json); - return Storage.writeFile(path, json); -} - -bool load(KOReaderCredentialStore& store, const char* json, bool* needsResave) { - if (needsResave) *needsResave = false; - JsonDocument doc; - auto error = deserializeJson(doc, json); - if (error) { - LOG_ERR("KRS", "JSON parse error: %s", error.c_str()); - return false; - } - - std::string user = doc["username"] | std::string(""); - - bool ok = false; - std::string pass = obfuscation::deobfuscateFromBase64(doc["password_obf"] | "", &ok); - if (!ok || pass.empty()) { - pass = doc["password"] | std::string(""); - if (!pass.empty() && needsResave) *needsResave = true; - } - - store.setCredentials(user, pass); - store.setServerUrl(doc["serverUrl"] | std::string("")); - - uint8_t method = doc["matchMethod"] | (uint8_t)0; - store.setMatchMethod(static_cast(method)); - - return true; -} - -} // namespace KOReaderJsonIO diff --git a/lib/KOReaderSync/KOReaderJsonIO.h b/lib/KOReaderSync/KOReaderJsonIO.h deleted file mode 100644 index c8d8eb81..00000000 --- a/lib/KOReaderSync/KOReaderJsonIO.h +++ /dev/null @@ -1,8 +0,0 @@ -#pragma once - -class KOReaderCredentialStore; - -namespace KOReaderJsonIO { -bool save(const KOReaderCredentialStore& store, const char* path); -bool load(KOReaderCredentialStore& store, const char* json, bool* needsResave); -} // namespace KOReaderJsonIO diff --git a/lib/Serialization/PersistableStore.cpp b/lib/Serialization/PersistableStore.cpp new file mode 100644 index 00000000..b9a2514f --- /dev/null +++ b/lib/Serialization/PersistableStore.cpp @@ -0,0 +1,45 @@ +#include "PersistableStore.h" + +#include +#include +#include + +bool PersistableStoreBase::writeDocToFile(const char* path, const JsonDocument& doc) { + Storage.mkdir("/.crosspoint"); + String json; + serializeJson(doc, json); + if (!Storage.writeFile(path, json)) { + LOG_ERR("PERSIST", "Failed to write %s", path); + return false; + } + return true; +} + +bool PersistableStoreBase::readDocFromFile(const char* path, JsonDocument& doc) { + if (!Storage.exists(path)) { + return false; // Expected on first boot — not an error. + } + String json = Storage.readFile(path); + if (json.isEmpty()) { + LOG_ERR("PERSIST", "Failed to read %s (empty)", path); + return false; + } + auto error = deserializeJson(doc, json); + if (error) { + LOG_ERR("PERSIST", "JSON parse error in %s: %s", path, error.c_str()); + return false; + } + return true; +} + +std::string PersistableStoreBase::extractPassword(JsonVariantConst doc, bool& needsResave) { + bool ok = false; + std::string pass = obfuscation::deobfuscateFromBase64(doc["password_obf"] | "", &ok); + if (!ok) { + // Deobfuscation failed — fall back to legacy plaintext password. + pass = doc["password"] | ""; + if (!pass.empty()) needsResave = true; + } + // A successfully decoded empty string is a legitimate value; preserve as-is. + return pass; +} diff --git a/lib/Serialization/PersistableStore.h b/lib/Serialization/PersistableStore.h new file mode 100644 index 00000000..14c6020c --- /dev/null +++ b/lib/Serialization/PersistableStore.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include + +#include + +/** + * @brief Non-template core of PersistableStore. + * + * All ArduinoJson parse/serialize machinery is instantiated once here (in + * PersistableStore.cpp) instead of in every store's translation unit. GCC + * emits the JSON serializer/parser templates as local .isra clones per TU + * (~0.5KB each), so keeping serializeJson/deserializeJson out of the stores + * is what makes the abstraction flash-neutral. + */ +class PersistableStoreBase { + protected: + PersistableStoreBase() = default; + ~PersistableStoreBase() = default; + + // Serializes doc and writes it to path (ensures /.crosspoint exists). Logs on failure. + static bool writeDocToFile(const char* path, const JsonDocument& doc); + + // Reads path and parses it into doc. Returns false silently when the file + // does not exist (expected on first boot); logs on read/parse failure. + static bool readDocFromFile(const char* path, JsonDocument& doc); + + /** + * Helper function for extracting an obfuscated password from a JSON value. + * Accepts JsonVariantConst so callers can pass either a whole JsonDocument + * or a JsonObject element (e.g. inside an array iteration). + * If the decoded password requires a resave (e.g. from plaintext fallback), `needsResave` is set to true. + */ + static std::string extractPassword(JsonVariantConst doc, bool& needsResave); +}; + +/** + * @brief Base class for persistable singletons using CRTP. + * + * Derived classes must provide: + * - A private default constructor + * - friend class PersistableStore; + * - static const char* getFilePath(); + * - void toJson(JsonDocument& doc) const; + * - bool fromJson(JsonVariantConst doc); + * + * Note for implementers: read string values as `const char*` (e.g. + * `obj["name"] | ""`), never as `| std::string("")` — ArduinoJson's + * std::string converter drags a per-TU copy of the whole JSON serializer + * into flash via its serializeJson fallback. + */ +template +class PersistableStore : public PersistableStoreBase { + protected: + PersistableStore() = default; + ~PersistableStore() = default; + + public: + // Delete copy constructor and assignment + PersistableStore(const PersistableStore&) = delete; + PersistableStore& operator=(const PersistableStore&) = delete; + + static T& getInstance() { + static T instance; + return instance; + } + + bool saveToFile() const { + JsonDocument doc; + static_cast(this)->toJson(doc); + return writeDocToFile(T::getFilePath(), doc); + } + + bool loadFromFile() { + JsonDocument doc; + if (!readDocFromFile(T::getFilePath(), doc)) { + return false; + } + return static_cast(this)->fromJson(doc.as()); + } +}; diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 3b5b5dcb..f5501f13 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -24,7 +24,7 @@ void readAndValidate(HalFile& file, uint8_t& member, const uint8_t maxValue) { } namespace { -constexpr uint8_t SETTINGS_FILE_VERSION = 1; +constexpr uint8_t SETTINGS_FILE_VERSION = 2; constexpr char SETTINGS_FILE_BIN[] = "/.crosspoint/settings.bin"; constexpr char SETTINGS_FILE_JSON[] = "/.crosspoint/settings.json"; constexpr char SETTINGS_FILE_BAK[] = "/.crosspoint/settings.bin.bak"; @@ -229,13 +229,6 @@ bool CrossPointSettings::loadFromBinaryFile() { if (++settingsRead >= fileSettingsCount) break; readAndValidate(inputFile, sleepScreenCoverMode, SLEEP_SCREEN_COVER_MODE_COUNT); if (++settingsRead >= fileSettingsCount) break; - { - std::string urlStr; - serialization::readString(inputFile, urlStr); - strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1); - opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0'; - } - if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, textAntiAliasing); if (++settingsRead >= fileSettingsCount) break; readAndValidate(inputFile, hideBatteryPercentage, HIDE_BATTERY_PERCENTAGE_COUNT); @@ -244,20 +237,6 @@ bool CrossPointSettings::loadFromBinaryFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, hyphenationEnabled); if (++settingsRead >= fileSettingsCount) break; - { - std::string usernameStr; - serialization::readString(inputFile, usernameStr); - strncpy(opdsUsername, usernameStr.c_str(), sizeof(opdsUsername) - 1); - opdsUsername[sizeof(opdsUsername) - 1] = '\0'; - } - if (++settingsRead >= fileSettingsCount) break; - { - std::string passwordStr; - serialization::readString(inputFile, passwordStr); - strncpy(opdsPassword, passwordStr.c_str(), sizeof(opdsPassword) - 1); - opdsPassword[sizeof(opdsPassword) - 1] = '\0'; - } - if (++settingsRead >= fileSettingsCount) break; readAndValidate(inputFile, sleepScreenCoverFilter, SLEEP_SCREEN_COVER_FILTER_COUNT); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, uiTheme); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 717942cc..5df277c6 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -238,10 +238,6 @@ class CrossPointSettings { // Reader screen margin settings uint8_t screenMargin = 5; - // OPDS browser settings - char opdsServerUrl[128] = ""; - char opdsUsername[64] = ""; - char opdsPassword[64] = ""; // Hide battery percentage uint8_t hideBatteryPercentage = HIDE_NEVER; // Long-press page turn button behavior diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 92139b69..21701efd 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -266,150 +266,6 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool* return true; } -// ---- WifiCredentialStore ---- - -bool JsonSettingsIO::saveWifi(const WifiCredentialStore& store, const char* path) { - JsonDocument doc; - doc["lastConnectedSsid"] = store.getLastConnectedSsid(); - - JsonArray arr = doc["credentials"].to(); - for (const auto& cred : store.getCredentials()) { - JsonObject obj = arr.add(); - obj["ssid"] = cred.ssid; - obj["password_obf"] = obfuscation::obfuscateToBase64(cred.password); - } - - String json; - serializeJson(doc, json); - return Storage.writeFile(path, json); -} - -bool JsonSettingsIO::loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave) { - if (needsResave) *needsResave = false; - JsonDocument doc; - auto error = deserializeJson(doc, json); - if (error) { - LOG_ERR("WCS", "JSON parse error: %s", error.c_str()); - return false; - } - - store.lastConnectedSsid = doc["lastConnectedSsid"] | std::string(""); - - store.credentials.clear(); - JsonArray arr = doc["credentials"].as(); - for (JsonObject obj : arr) { - if (store.credentials.size() >= store.MAX_NETWORKS) break; - WifiCredential cred; - cred.ssid = obj["ssid"] | std::string(""); - bool ok = false; - cred.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok); - if (!ok || cred.password.empty()) { - cred.password = obj["password"] | std::string(""); - if (!cred.password.empty() && needsResave) *needsResave = true; - } - store.credentials.push_back(cred); - } - - LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", store.credentials.size()); - return true; -} - -// ---- RecentBooksStore ---- - -bool JsonSettingsIO::saveRecentBooks(const RecentBooksStore& store, const char* path) { - JsonDocument doc; - JsonArray arr = doc["books"].to(); - for (const auto& book : store.getBooks()) { - JsonObject obj = arr.add(); - obj["path"] = book.path; - obj["title"] = book.title; - obj["author"] = book.author; - obj["coverBmpPath"] = book.coverBmpPath; - } - - String json; - serializeJson(doc, json); - return Storage.writeFile(path, json); -} - -bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json) { - JsonDocument doc; - auto error = deserializeJson(doc, json); - if (error) { - LOG_ERR("RBS", "JSON parse error: %s", error.c_str()); - return false; - } - - store.recentBooks.clear(); - JsonArray arr = doc["books"].as(); - store.recentBooks.reserve(std::min(arr.size(), (size_t)10)); - for (JsonObject obj : arr) { - if (store.getCount() >= 10) break; - RecentBook book; - book.path = obj["path"] | std::string(""); - book.title = obj["title"] | std::string(""); - book.author = obj["author"] | std::string(""); - book.coverBmpPath = obj["coverBmpPath"] | std::string(""); - store.recentBooks.push_back(book); - } - - LOG_DBG("RBS", "Recent books loaded from file (%d entries)", store.getCount()); - return true; -} - -// ---- OpdsServerStore ---- -// Follows the same save/load pattern as WifiCredentialStore above. -// Passwords are XOR-obfuscated with the device MAC and base64-encoded ("password_obf" key). - -bool JsonSettingsIO::saveOpds(const OpdsServerStore& store, const char* path) { - JsonDocument doc; - - JsonArray arr = doc["servers"].to(); - for (const auto& server : store.getServers()) { - JsonObject obj = arr.add(); - obj["name"] = server.name; - obj["url"] = server.url; - obj["username"] = server.username; - obj["password_obf"] = obfuscation::obfuscateToBase64(server.password); - } - - String json; - serializeJson(doc, json); - return Storage.writeFile(path, json); -} - -bool JsonSettingsIO::loadOpds(OpdsServerStore& store, const char* json, bool* needsResave) { - if (needsResave) *needsResave = false; - JsonDocument doc; - auto error = deserializeJson(doc, json); - if (error) { - LOG_ERR("OPS", "JSON parse error: %s", error.c_str()); - return false; - } - - store.servers.clear(); - JsonArray arr = doc["servers"].as(); - for (JsonObject obj : arr) { - if (store.servers.size() >= OpdsServerStore::MAX_SERVERS) break; - OpdsServer server; - server.name = obj["name"] | std::string(""); - server.url = obj["url"] | std::string(""); - server.username = obj["username"] | std::string(""); - // Try the obfuscated key first; fall back to plaintext "password" for - // files written before obfuscation was added (or hand-edited JSON). - bool ok = false; - server.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok); - if (!ok || server.password.empty()) { - server.password = obj["password"] | std::string(""); - if (!server.password.empty() && needsResave) *needsResave = true; - } - store.servers.push_back(std::move(server)); - } - - LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size()); - return true; -} - // ---- Bookmarks ---- bool JsonSettingsIO::saveBookmarks(const std::vector& bookmarks, const char* path) { diff --git a/src/JsonSettingsIO.h b/src/JsonSettingsIO.h index 45888b67..487d204a 100644 --- a/src/JsonSettingsIO.h +++ b/src/JsonSettingsIO.h @@ -19,18 +19,6 @@ bool loadSettings(CrossPointSettings& s, const char* json, bool* needsResave = n bool saveState(const CrossPointState& s, const char* path); bool loadState(CrossPointState& s, const char* json); -// WifiCredentialStore -bool saveWifi(const WifiCredentialStore& store, const char* path); -bool loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave = nullptr); - -// RecentBooksStore -bool saveRecentBooks(const RecentBooksStore& store, const char* path); -bool loadRecentBooks(RecentBooksStore& store, const char* json); - -// OpdsServerStore -bool saveOpds(const OpdsServerStore& store, const char* path); -bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr); - // Bookmarks bool saveBookmarks(const std::vector& bookmarks, const char* path); bool loadBookmarks(std::vector& bookmarks, const char* json); diff --git a/src/OpdsServerStore.cpp b/src/OpdsServerStore.cpp index b2151682..14ae9998 100644 --- a/src/OpdsServerStore.cpp +++ b/src/OpdsServerStore.cpp @@ -1,74 +1,48 @@ #include "OpdsServerStore.h" -#include -#include #include +#include +#include #include -#include "CrossPointSettings.h" - -OpdsServerStore OpdsServerStore::instance; - -namespace { -constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json"; -} // namespace - -bool OpdsServerStore::saveToFile() const { - Storage.mkdir("/.crosspoint"); - return JsonSettingsIO::saveOpds(*this, OPDS_FILE_JSON); +void OpdsServerStore::toJson(JsonDocument& doc) const { + JsonArray arr = doc["servers"].to(); + for (const auto& server : servers) { + JsonObject obj = arr.add(); + obj["name"] = server.name; + obj["url"] = server.url; + obj["username"] = server.username; + obj["password_obf"] = obfuscation::obfuscateToBase64(server.password); + } } -bool OpdsServerStore::loadFromFile() { - if (Storage.exists(OPDS_FILE_JSON)) { - String json = Storage.readFile(OPDS_FILE_JSON); - if (!json.isEmpty()) { - // resave flag is set when passwords were stored in plaintext and need re-obfuscation - bool resave = false; - bool result = JsonSettingsIO::loadOpds(*this, json.c_str(), &resave); - if (result && resave) { - LOG_DBG("OPS", "Resaving JSON with obfuscated passwords"); - saveToFile(); - } - return result; - } - } - - // No opds.json found — attempt one-time migration from the legacy single-server - // fields in CrossPointSettings (opdsServerUrl/opdsUsername/opdsPassword). - if (migrateFromSettings()) { - LOG_DBG("OPS", "Migrated legacy OPDS settings"); - return true; - } - - return false; -} - -bool OpdsServerStore::migrateFromSettings() { - if (strlen(SETTINGS.opdsServerUrl) == 0) { - return false; - } - - OpdsServer server; - server.name = "OPDS Server"; - server.url = SETTINGS.opdsServerUrl; - server.username = SETTINGS.opdsUsername; - server.password = SETTINGS.opdsPassword; - servers.push_back(std::move(server)); - - if (saveToFile()) { - // Clear legacy fields so migration won't run again on next boot - SETTINGS.opdsServerUrl[0] = '\0'; - SETTINGS.opdsUsername[0] = '\0'; - SETTINGS.opdsPassword[0] = '\0'; - SETTINGS.saveToFile(); - LOG_DBG("OPS", "Migrated single-server OPDS config to opds.json"); - return true; - } - - // Save failed — roll back in-memory state so we don't have a partial migration +bool OpdsServerStore::fromJson(JsonVariantConst doc) { + // Tolerate a missing/invalid 'servers' key (treat as empty list); only a + // JSON parse error is fatal. A null JsonArray iterates zero times. servers.clear(); - return false; + JsonArrayConst arr = doc["servers"].as(); + servers.reserve(std::min(arr.size(), MAX_SERVERS)); + bool needsResave = false; + + for (JsonObjectConst obj : arr) { + if (servers.size() >= OpdsServerStore::MAX_SERVERS) break; + OpdsServer server; + server.name = obj["name"] | ""; + server.url = obj["url"] | ""; + server.username = obj["username"] | ""; + server.password = extractPassword(obj, needsResave); + servers.push_back(std::move(server)); + } + + LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size()); + + if (needsResave) { + LOG_DBG("OPS", "Resaving JSON with obfuscated passwords"); + saveToFile(); + } + + return true; } bool OpdsServerStore::addServer(const OpdsServer& server) { diff --git a/src/OpdsServerStore.h b/src/OpdsServerStore.h index 87571f65..3845b1f3 100644 --- a/src/OpdsServerStore.h +++ b/src/OpdsServerStore.h @@ -1,4 +1,7 @@ #pragma once +#include +#include + #include #include @@ -9,37 +12,25 @@ struct OpdsServer { std::string password; // Plaintext in memory; obfuscated with hardware key on disk }; -class OpdsServerStore; -namespace JsonSettingsIO { -bool saveOpds(const OpdsServerStore& store, const char* path); -bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave); -} // namespace JsonSettingsIO - /** * Singleton class for storing OPDS server configurations on the SD card. * Passwords are XOR-obfuscated with the device's unique hardware MAC address * and base64-encoded before writing to JSON. */ -class OpdsServerStore { +class OpdsServerStore : public PersistableStore { private: - static OpdsServerStore instance; std::vector servers; static constexpr size_t MAX_SERVERS = 8; OpdsServerStore() = default; - friend bool JsonSettingsIO::saveOpds(const OpdsServerStore&, const char*); - friend bool JsonSettingsIO::loadOpds(OpdsServerStore&, const char*, bool*); + friend class PersistableStore; public: - OpdsServerStore(const OpdsServerStore&) = delete; - OpdsServerStore& operator=(const OpdsServerStore&) = delete; - - static OpdsServerStore& getInstance() { return instance; } - - bool saveToFile() const; - bool loadFromFile(); + static const char* getFilePath() { return "/.crosspoint/opds.json"; } + void toJson(JsonDocument& doc) const; + bool fromJson(JsonVariantConst doc); bool addServer(const OpdsServer& server); bool updateServer(size_t index, const OpdsServer& server); @@ -49,12 +40,6 @@ class OpdsServerStore { const OpdsServer* getServer(size_t index) const; size_t getCount() const { return servers.size(); } bool hasServers() const { return !servers.empty(); } - - /** - * Migrate from legacy single-server settings in CrossPointSettings. - * Called once during first load if no opds.json exists. - */ - bool migrateFromSettings(); }; #define OPDS_STORE OpdsServerStore::getInstance() diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index af6e9d4c..897e8a54 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -3,23 +3,42 @@ #include #include #include -#include #include -#include #include #include #include -namespace { -constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 3; -constexpr char RECENT_BOOKS_FILE_BIN[] = "/.crosspoint/recent.bin"; -constexpr char RECENT_BOOKS_FILE_JSON[] = "/.crosspoint/recent.json"; -constexpr char RECENT_BOOKS_FILE_BAK[] = "/.crosspoint/recent.bin.bak"; -constexpr int MAX_RECENT_BOOKS = 10; -} // namespace +void RecentBooksStore::toJson(JsonDocument& doc) const { + JsonArray arr = doc["books"].to(); + for (const auto& book : recentBooks) { + JsonObject obj = arr.add(); + obj["path"] = book.path; + obj["title"] = book.title; + obj["author"] = book.author; + obj["coverBmpPath"] = book.coverBmpPath; + } +} -RecentBooksStore RecentBooksStore::instance; +bool RecentBooksStore::fromJson(JsonVariantConst doc) { + // Tolerate a missing/invalid 'books' key (treat as empty list); only a + // JSON parse error is fatal. A null JsonArray iterates zero times. + recentBooks.clear(); + JsonArrayConst arr = doc["books"].as(); + recentBooks.reserve(std::min(arr.size(), static_cast(MAX_RECENT_BOOKS))); + for (JsonObjectConst obj : arr) { + if (getCount() >= MAX_RECENT_BOOKS) break; + RecentBook book; + book.path = obj["path"] | ""; + book.title = obj["title"] | ""; + book.author = obj["author"] | ""; + book.coverBmpPath = obj["coverBmpPath"] | ""; + recentBooks.push_back(book); + } + + LOG_DBG("RBS", "Recent books loaded from file (%d entries)", getCount()); + return true; +} void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author, const std::string& coverBmpPath) { @@ -92,11 +111,6 @@ bool RecentBooksStore::pruneMissing() { return recentBooks.size() != before; } -bool RecentBooksStore::saveToFile() const { - Storage.mkdir("/.crosspoint"); - return JsonSettingsIO::saveRecentBooks(*this, RECENT_BOOKS_FILE_JSON); -} - RecentBook RecentBooksStore::getDataFromBook(std::string path) const { std::string lastBookFileName = ""; const size_t lastSlash = path.find_last_of('/'); @@ -124,95 +138,3 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const { } return RecentBook{path, "", "", ""}; } - -bool RecentBooksStore::loadFromFile() { - // Try JSON first - if (Storage.exists(RECENT_BOOKS_FILE_JSON)) { - String json = Storage.readFile(RECENT_BOOKS_FILE_JSON); - if (!json.isEmpty()) { - return JsonSettingsIO::loadRecentBooks(*this, json.c_str()); - } - } - - // Fall back to binary migration - if (Storage.exists(RECENT_BOOKS_FILE_BIN)) { - if (loadFromBinaryFile()) { - saveToFile(); - Storage.rename(RECENT_BOOKS_FILE_BIN, RECENT_BOOKS_FILE_BAK); - LOG_DBG("RBS", "Migrated recent.bin to recent.json"); - return true; - } - } - - return false; -} - -bool RecentBooksStore::loadFromBinaryFile() { - HalFile inputFile; - if (!Storage.openFileForRead("RBS", RECENT_BOOKS_FILE_BIN, inputFile)) { - return false; - } - - uint8_t version; - serialization::readPod(inputFile, version); - if (version == 1 || version == 2) { - // Old version, just read paths - uint8_t count; - serialization::readPod(inputFile, count); - recentBooks.clear(); - recentBooks.reserve(count); - for (uint8_t i = 0; i < count; i++) { - std::string path; - serialization::readString(inputFile, path); - - // load book to get missing data - RecentBook book = getDataFromBook(path); - if (book.title.empty() && book.author.empty() && version == 2) { - // Fall back to loading what we can from the store - std::string title, author; - serialization::readString(inputFile, title); - serialization::readString(inputFile, author); - recentBooks.push_back({path, title, author, ""}); - } else { - recentBooks.push_back(book); - } - } - } else if (version == 3) { - uint8_t count; - serialization::readPod(inputFile, count); - - recentBooks.clear(); - recentBooks.reserve(count); - uint8_t omitted = 0; - - for (uint8_t i = 0; i < count; i++) { - std::string path, title, author, coverBmpPath; - serialization::readString(inputFile, path); - serialization::readString(inputFile, title); - serialization::readString(inputFile, author); - serialization::readString(inputFile, coverBmpPath); - - // Omit books with missing title (e.g. saved before metadata was available) - if (title.empty()) { - omitted++; - continue; - } - - recentBooks.push_back({path, title, author, coverBmpPath}); - } - - if (omitted > 0) { - // Explicitly close() file before saveToFile() rewrites the same file - inputFile.close(); - saveToFile(); - LOG_DBG("RBS", "Omitted %u recent book(s) with missing title", omitted); - return true; - } - } else { - LOG_ERR("RBS", "Deserialization failed: Unknown version %u", version); - return false; - } - - LOG_DBG("RBS", "Recent books loaded from binary file (%d entries)", static_cast(recentBooks.size())); - return true; -} diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h index f3a8391e..37f39047 100644 --- a/src/RecentBooksStore.h +++ b/src/RecentBooksStore.h @@ -1,4 +1,7 @@ #pragma once +#include +#include + #include #include @@ -11,24 +14,21 @@ struct RecentBook { bool operator==(const RecentBook& other) const { return path == other.path; } }; -class RecentBooksStore; -namespace JsonSettingsIO { -bool loadRecentBooks(RecentBooksStore& store, const char* json); -} // namespace JsonSettingsIO - -class RecentBooksStore { - // Static instance - static RecentBooksStore instance; - +class RecentBooksStore : public PersistableStore { + private: std::vector recentBooks; - friend bool JsonSettingsIO::loadRecentBooks(RecentBooksStore&, const char*); + static constexpr int MAX_RECENT_BOOKS = 10; - public: + RecentBooksStore() = default; ~RecentBooksStore() = default; - // Get singleton instance - static RecentBooksStore& getInstance() { return instance; } + friend class PersistableStore; + + public: + static const char* getFilePath() { return "/.crosspoint/recent.json"; } + void toJson(JsonDocument& doc) const; + bool fromJson(JsonVariantConst doc); // Add a book to the recent list (moves to front if already exists) void addBook(const std::string& path, const std::string& title, const std::string& author, @@ -61,13 +61,7 @@ class RecentBooksStore { // Get the count of recent books int getCount() const { return static_cast(recentBooks.size()); } - bool saveToFile() const; - - bool loadFromFile(); RecentBook getDataFromBook(std::string path) const; - - private: - bool loadFromBinaryFile(); }; // Helper macro to access recent books store diff --git a/src/WifiCredentialStore.cpp b/src/WifiCredentialStore.cpp index 6827ad5c..532b925e 100644 --- a/src/WifiCredentialStore.cpp +++ b/src/WifiCredentialStore.cpp @@ -1,106 +1,46 @@ #include "WifiCredentialStore.h" -#include -#include #include #include -#include #include -// Initialize the static instance -WifiCredentialStore WifiCredentialStore::instance; +void WifiCredentialStore::toJson(JsonDocument& doc) const { + doc["lastConnectedSsid"] = lastConnectedSsid; -namespace { -// File format version (for binary migration) -constexpr uint8_t WIFI_FILE_VERSION = 2; - -// File paths -constexpr char WIFI_FILE_BIN[] = "/.crosspoint/wifi.bin"; -constexpr char WIFI_FILE_JSON[] = "/.crosspoint/wifi.json"; -constexpr char WIFI_FILE_BAK[] = "/.crosspoint/wifi.bin.bak"; - -// Legacy obfuscation key - "CrossPoint" in ASCII (only used for binary migration) -constexpr uint8_t LEGACY_OBFUSCATION_KEY[] = {0x43, 0x72, 0x6F, 0x73, 0x73, 0x50, 0x6F, 0x69, 0x6E, 0x74}; -constexpr size_t LEGACY_KEY_LENGTH = sizeof(LEGACY_OBFUSCATION_KEY); - -void legacyDeobfuscate(std::string& data) { - for (size_t i = 0; i < data.size(); i++) { - data[i] ^= LEGACY_OBFUSCATION_KEY[i % LEGACY_KEY_LENGTH]; + JsonArray arr = doc["credentials"].to(); + for (const auto& cred : credentials) { + JsonObject obj = arr.add(); + obj["ssid"] = cred.ssid; + obj["password_obf"] = obfuscation::obfuscateToBase64(cred.password); } } -} // namespace -bool WifiCredentialStore::saveToFile() const { - Storage.mkdir("/.crosspoint"); - return JsonSettingsIO::saveWifi(*this, WIFI_FILE_JSON); -} - -bool WifiCredentialStore::loadFromFile() { - // Try JSON first - if (Storage.exists(WIFI_FILE_JSON)) { - String json = Storage.readFile(WIFI_FILE_JSON); - if (!json.isEmpty()) { - bool resave = false; - bool result = JsonSettingsIO::loadWifi(*this, json.c_str(), &resave); - if (result && resave) { - LOG_DBG("WCS", "Resaving JSON with obfuscated passwords"); - saveToFile(); - } - return result; - } - } - - // Fall back to binary migration - if (Storage.exists(WIFI_FILE_BIN)) { - if (loadFromBinaryFile()) { - if (saveToFile()) { - Storage.rename(WIFI_FILE_BIN, WIFI_FILE_BAK); - LOG_DBG("WCS", "Migrated wifi.bin to wifi.json"); - return true; - } else { - LOG_ERR("WCS", "Failed to save wifi during migration"); - return false; - } - } - } - - return false; -} - -bool WifiCredentialStore::loadFromBinaryFile() { - HalFile file; - if (!Storage.openFileForRead("WCS", WIFI_FILE_BIN, file)) { - return false; - } - - uint8_t version; - serialization::readPod(file, version); - if (version > WIFI_FILE_VERSION) { - LOG_DBG("WCS", "Unknown file version: %u", version); - return false; - } - - if (version >= 2) { - serialization::readString(file, lastConnectedSsid); - } else { - lastConnectedSsid.clear(); - } - - uint8_t count; - serialization::readPod(file, count); +bool WifiCredentialStore::fromJson(JsonVariantConst doc) { + lastConnectedSsid = doc["lastConnectedSsid"] | ""; + // Tolerate a missing/invalid 'credentials' key (treat as empty list); only + // a JSON parse error is fatal. A null JsonArray iterates zero times. credentials.clear(); - credentials.reserve(std::min(count, MAX_NETWORKS)); - for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) { + JsonArrayConst arr = doc["credentials"].as(); + credentials.reserve(std::min(arr.size(), MAX_NETWORKS)); + bool needsResave = false; + + for (JsonObjectConst obj : arr) { + if (credentials.size() >= MAX_NETWORKS) break; WifiCredential cred; - serialization::readString(file, cred.ssid); - serialization::readString(file, cred.password); - legacyDeobfuscate(cred.password); + cred.ssid = obj["ssid"] | ""; + cred.password = extractPassword(obj, needsResave); credentials.push_back(cred); } - // LOG_DBG("WCS", "Loaded %zu WiFi credentials from binary file", credentials.size()); + LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", credentials.size()); + + if (needsResave) { + LOG_DBG("WCS", "Resaving JSON with obfuscated passwords"); + saveToFile(); + } + return true; } diff --git a/src/WifiCredentialStore.h b/src/WifiCredentialStore.h index 46650954..8a95ebe9 100644 --- a/src/WifiCredentialStore.h +++ b/src/WifiCredentialStore.h @@ -1,4 +1,7 @@ #pragma once +#include +#include + #include #include @@ -7,21 +10,14 @@ struct WifiCredential { std::string password; // Plaintext in memory; obfuscated with hardware key on disk }; -class WifiCredentialStore; -namespace JsonSettingsIO { -bool saveWifi(const WifiCredentialStore& store, const char* path); -bool loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave); -} // namespace JsonSettingsIO - /** * Singleton class for storing WiFi credentials on the SD card. * Passwords are XOR-obfuscated with the device's unique hardware MAC address * and base64-encoded before writing to JSON (not cryptographically secure, * but prevents casual reading and ties credentials to the specific device). */ -class WifiCredentialStore { +class WifiCredentialStore : public PersistableStore { private: - static WifiCredentialStore instance; std::vector credentials; std::string lastConnectedSsid; @@ -30,22 +26,12 @@ class WifiCredentialStore { // Private constructor for singleton WifiCredentialStore() = default; - bool loadFromBinaryFile(); - - friend bool JsonSettingsIO::saveWifi(const WifiCredentialStore&, const char*); - friend bool JsonSettingsIO::loadWifi(WifiCredentialStore&, const char*, bool*); + friend class PersistableStore; public: - // Delete copy constructor and assignment - WifiCredentialStore(const WifiCredentialStore&) = delete; - WifiCredentialStore& operator=(const WifiCredentialStore&) = delete; - - // Get singleton instance - static WifiCredentialStore& getInstance() { return instance; } - - // Save/load from SD card - bool saveToFile() const; - bool loadFromFile(); + static const char* getFilePath() { return "/.crosspoint/wifi.json"; } + void toJson(JsonDocument& doc) const; + bool fromJson(JsonVariantConst doc); // Credential management bool addCredential(const std::string& ssid, const std::string& password);