chore: Refactor stores to use PersistableStore CRTP template (#2464)
This commit is contained in:
@@ -1,118 +1,43 @@
|
|||||||
#include "KOReaderCredentialStore.h"
|
#include "KOReaderCredentialStore.h"
|
||||||
|
|
||||||
#include <HalStorage.h>
|
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <MD5Builder.h>
|
#include <MD5Builder.h>
|
||||||
#include <ObfuscationUtils.h>
|
#include <ObfuscationUtils.h>
|
||||||
#include <Serialization.h>
|
|
||||||
|
|
||||||
#include "KOReaderJsonIO.h"
|
|
||||||
|
|
||||||
// Initialize the static instance
|
|
||||||
KOReaderCredentialStore KOReaderCredentialStore::instance;
|
|
||||||
|
|
||||||
namespace {
|
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
|
// Default sync server URL
|
||||||
constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
|
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
|
} // namespace
|
||||||
|
|
||||||
bool KOReaderCredentialStore::saveToFile() const {
|
void KOReaderCredentialStore::toJson(JsonDocument& doc) const {
|
||||||
Storage.mkdir("/.crosspoint");
|
doc["username"] = getUsername();
|
||||||
return KOReaderJsonIO::save(*this, KOREADER_FILE_JSON);
|
doc["password_obf"] = obfuscation::obfuscateToBase64(getPassword());
|
||||||
|
doc["serverUrl"] = getServerUrl();
|
||||||
|
doc["matchMethod"] = static_cast<uint8_t>(getMatchMethod());
|
||||||
}
|
}
|
||||||
|
|
||||||
bool KOReaderCredentialStore::loadFromFile() {
|
bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
|
||||||
// Try JSON first
|
std::string user = doc["username"] | "";
|
||||||
if (Storage.exists(KOREADER_FILE_JSON)) {
|
|
||||||
String json = Storage.readFile(KOREADER_FILE_JSON);
|
bool needsResave = false;
|
||||||
if (!json.isEmpty()) {
|
std::string pass = extractPassword(doc, needsResave);
|
||||||
bool resave = false;
|
|
||||||
bool result = KOReaderJsonIO::load(*this, json.c_str(), &resave);
|
setCredentials(user, pass);
|
||||||
if (result && resave) {
|
setServerUrl(doc["serverUrl"] | "");
|
||||||
saveToFile();
|
|
||||||
|
uint8_t method = doc["matchMethod"] | (uint8_t)0;
|
||||||
|
if (method <= static_cast<uint8_t>(DocumentMatchMethod::BINARY)) {
|
||||||
|
setMatchMethod(static_cast<DocumentMatchMethod>(method));
|
||||||
|
} else {
|
||||||
|
LOG_DBG("KRS", "Invalid matchMethod %u in JSON, resetting to FILENAME", method);
|
||||||
|
setMatchMethod(DocumentMatchMethod::FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsResave) {
|
||||||
LOG_DBG("KRS", "Resaved KOReader credentials to update format");
|
LOG_DBG("KRS", "Resaved KOReader credentials to update format");
|
||||||
}
|
saveToFile();
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_DBG("KRS", "No credentials file found");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
} else {
|
|
||||||
username.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.available()) {
|
|
||||||
serialization::readString(file, password);
|
|
||||||
legacyDeobfuscate(password);
|
|
||||||
} else {
|
|
||||||
password.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.available()) {
|
|
||||||
serialization::readString(file, serverUrl);
|
|
||||||
} else {
|
|
||||||
serverUrl.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.available()) {
|
|
||||||
uint8_t method;
|
|
||||||
serialization::readPod(file, method);
|
|
||||||
matchMethod = static_cast<DocumentMatchMethod>(method);
|
|
||||||
} else {
|
|
||||||
matchMethod = DocumentMatchMethod::FILENAME;
|
|
||||||
}
|
|
||||||
|
|
||||||
LOG_DBG("KRS", "Loaded KOReader credentials from binary for user: %s", username.c_str());
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <PersistableStore.h>
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -14,9 +17,9 @@ enum class DocumentMatchMethod : uint8_t {
|
|||||||
* and base64-encoded before writing to JSON (not cryptographically secure,
|
* and base64-encoded before writing to JSON (not cryptographically secure,
|
||||||
* but prevents casual reading and ties credentials to the specific device).
|
* but prevents casual reading and ties credentials to the specific device).
|
||||||
*/
|
*/
|
||||||
class KOReaderCredentialStore {
|
|
||||||
|
class KOReaderCredentialStore : public PersistableStore<KOReaderCredentialStore> {
|
||||||
private:
|
private:
|
||||||
static KOReaderCredentialStore instance;
|
|
||||||
std::string username;
|
std::string username;
|
||||||
std::string password;
|
std::string password;
|
||||||
std::string serverUrl; // Custom sync server URL (empty = default)
|
std::string serverUrl; // Custom sync server URL (empty = default)
|
||||||
@@ -24,20 +27,14 @@ class KOReaderCredentialStore {
|
|||||||
|
|
||||||
// Private constructor for singleton
|
// Private constructor for singleton
|
||||||
KOReaderCredentialStore() = default;
|
KOReaderCredentialStore() = default;
|
||||||
|
~KOReaderCredentialStore() = default;
|
||||||
|
|
||||||
bool loadFromBinaryFile();
|
friend class PersistableStore<KOReaderCredentialStore>;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// Delete copy constructor and assignment
|
static const char* getFilePath() { return "/.crosspoint/koreader.json"; }
|
||||||
KOReaderCredentialStore(const KOReaderCredentialStore&) = delete;
|
void toJson(JsonDocument& doc) const;
|
||||||
KOReaderCredentialStore& operator=(const KOReaderCredentialStore&) = delete;
|
bool fromJson(JsonVariantConst doc);
|
||||||
|
|
||||||
// Get singleton instance
|
|
||||||
static KOReaderCredentialStore& getInstance() { return instance; }
|
|
||||||
|
|
||||||
// Save/load from SD card
|
|
||||||
bool saveToFile() const;
|
|
||||||
bool loadFromFile();
|
|
||||||
|
|
||||||
// Credential management
|
// Credential management
|
||||||
void setCredentials(const std::string& user, const std::string& pass);
|
void setCredentials(const std::string& user, const std::string& pass);
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
#include "KOReaderJsonIO.h"
|
|
||||||
|
|
||||||
#include <ArduinoJson.h>
|
|
||||||
#include <HalStorage.h>
|
|
||||||
#include <Logging.h>
|
|
||||||
#include <ObfuscationUtils.h>
|
|
||||||
|
|
||||||
#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<uint8_t>(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<DocumentMatchMethod>(method));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace KOReaderJsonIO
|
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#include "PersistableStore.h"
|
||||||
|
|
||||||
|
#include <HalStorage.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
#include <ObfuscationUtils.h>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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<Derived>;
|
||||||
|
* - 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 <typename T>
|
||||||
|
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<const T*>(this)->toJson(doc);
|
||||||
|
return writeDocToFile(T::getFilePath(), doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool loadFromFile() {
|
||||||
|
JsonDocument doc;
|
||||||
|
if (!readDocFromFile(T::getFilePath(), doc)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return static_cast<T*>(this)->fromJson(doc.as<JsonVariantConst>());
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -24,7 +24,7 @@ void readAndValidate(HalFile& file, uint8_t& member, const uint8_t maxValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
namespace {
|
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_BIN[] = "/.crosspoint/settings.bin";
|
||||||
constexpr char SETTINGS_FILE_JSON[] = "/.crosspoint/settings.json";
|
constexpr char SETTINGS_FILE_JSON[] = "/.crosspoint/settings.json";
|
||||||
constexpr char SETTINGS_FILE_BAK[] = "/.crosspoint/settings.bin.bak";
|
constexpr char SETTINGS_FILE_BAK[] = "/.crosspoint/settings.bin.bak";
|
||||||
@@ -229,13 +229,6 @@ bool CrossPointSettings::loadFromBinaryFile() {
|
|||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
readAndValidate(inputFile, sleepScreenCoverMode, SLEEP_SCREEN_COVER_MODE_COUNT);
|
readAndValidate(inputFile, sleepScreenCoverMode, SLEEP_SCREEN_COVER_MODE_COUNT);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
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);
|
serialization::readPod(inputFile, textAntiAliasing);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
readAndValidate(inputFile, hideBatteryPercentage, HIDE_BATTERY_PERCENTAGE_COUNT);
|
readAndValidate(inputFile, hideBatteryPercentage, HIDE_BATTERY_PERCENTAGE_COUNT);
|
||||||
@@ -244,20 +237,6 @@ bool CrossPointSettings::loadFromBinaryFile() {
|
|||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
serialization::readPod(inputFile, hyphenationEnabled);
|
serialization::readPod(inputFile, hyphenationEnabled);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
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);
|
readAndValidate(inputFile, sleepScreenCoverFilter, SLEEP_SCREEN_COVER_FILTER_COUNT);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
serialization::readPod(inputFile, uiTheme);
|
serialization::readPod(inputFile, uiTheme);
|
||||||
|
|||||||
@@ -238,10 +238,6 @@ class CrossPointSettings {
|
|||||||
|
|
||||||
// Reader screen margin settings
|
// Reader screen margin settings
|
||||||
uint8_t screenMargin = 5;
|
uint8_t screenMargin = 5;
|
||||||
// OPDS browser settings
|
|
||||||
char opdsServerUrl[128] = "";
|
|
||||||
char opdsUsername[64] = "";
|
|
||||||
char opdsPassword[64] = "";
|
|
||||||
// Hide battery percentage
|
// Hide battery percentage
|
||||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||||
// Long-press page turn button behavior
|
// Long-press page turn button behavior
|
||||||
|
|||||||
@@ -266,150 +266,6 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- WifiCredentialStore ----
|
|
||||||
|
|
||||||
bool JsonSettingsIO::saveWifi(const WifiCredentialStore& store, const char* path) {
|
|
||||||
JsonDocument doc;
|
|
||||||
doc["lastConnectedSsid"] = store.getLastConnectedSsid();
|
|
||||||
|
|
||||||
JsonArray arr = doc["credentials"].to<JsonArray>();
|
|
||||||
for (const auto& cred : store.getCredentials()) {
|
|
||||||
JsonObject obj = arr.add<JsonObject>();
|
|
||||||
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<JsonArray>();
|
|
||||||
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<JsonArray>();
|
|
||||||
for (const auto& book : store.getBooks()) {
|
|
||||||
JsonObject obj = arr.add<JsonObject>();
|
|
||||||
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<JsonArray>();
|
|
||||||
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<JsonArray>();
|
|
||||||
for (const auto& server : store.getServers()) {
|
|
||||||
JsonObject obj = arr.add<JsonObject>();
|
|
||||||
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<JsonArray>();
|
|
||||||
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 ----
|
// ---- Bookmarks ----
|
||||||
|
|
||||||
bool JsonSettingsIO::saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path) {
|
bool JsonSettingsIO::saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path) {
|
||||||
|
|||||||
@@ -19,18 +19,6 @@ bool loadSettings(CrossPointSettings& s, const char* json, bool* needsResave = n
|
|||||||
bool saveState(const CrossPointState& s, const char* path);
|
bool saveState(const CrossPointState& s, const char* path);
|
||||||
bool loadState(CrossPointState& s, const char* json);
|
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
|
// Bookmarks
|
||||||
bool saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path);
|
bool saveBookmarks(const std::vector<BookmarkEntry>& bookmarks, const char* path);
|
||||||
bool loadBookmarks(std::vector<BookmarkEntry>& bookmarks, const char* json);
|
bool loadBookmarks(std::vector<BookmarkEntry>& bookmarks, const char* json);
|
||||||
|
|||||||
+32
-58
@@ -1,74 +1,48 @@
|
|||||||
#include "OpdsServerStore.h"
|
#include "OpdsServerStore.h"
|
||||||
|
|
||||||
#include <HalStorage.h>
|
|
||||||
#include <JsonSettingsIO.h>
|
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
|
#include <ObfuscationUtils.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
void OpdsServerStore::toJson(JsonDocument& doc) const {
|
||||||
|
JsonArray arr = doc["servers"].to<JsonArray>();
|
||||||
OpdsServerStore OpdsServerStore::instance;
|
for (const auto& server : servers) {
|
||||||
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
namespace {
|
obj["name"] = server.name;
|
||||||
constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json";
|
obj["url"] = server.url;
|
||||||
} // namespace
|
obj["username"] = server.username;
|
||||||
|
obj["password_obf"] = obfuscation::obfuscateToBase64(server.password);
|
||||||
bool OpdsServerStore::saveToFile() const {
|
}
|
||||||
Storage.mkdir("/.crosspoint");
|
|
||||||
return JsonSettingsIO::saveOpds(*this, OPDS_FILE_JSON);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool OpdsServerStore::loadFromFile() {
|
bool OpdsServerStore::fromJson(JsonVariantConst doc) {
|
||||||
if (Storage.exists(OPDS_FILE_JSON)) {
|
// Tolerate a missing/invalid 'servers' key (treat as empty list); only a
|
||||||
String json = Storage.readFile(OPDS_FILE_JSON);
|
// JSON parse error is fatal. A null JsonArray iterates zero times.
|
||||||
if (!json.isEmpty()) {
|
servers.clear();
|
||||||
// resave flag is set when passwords were stored in plaintext and need re-obfuscation
|
JsonArrayConst arr = doc["servers"].as<JsonArrayConst>();
|
||||||
bool resave = false;
|
servers.reserve(std::min(arr.size(), MAX_SERVERS));
|
||||||
bool result = JsonSettingsIO::loadOpds(*this, json.c_str(), &resave);
|
bool needsResave = false;
|
||||||
if (result && resave) {
|
|
||||||
|
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");
|
LOG_DBG("OPS", "Resaving JSON with obfuscated passwords");
|
||||||
saveToFile();
|
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 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
|
|
||||||
servers.clear();
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool OpdsServerStore::addServer(const OpdsServer& server) {
|
bool OpdsServerStore::addServer(const OpdsServer& server) {
|
||||||
|
|||||||
+8
-23
@@ -1,4 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <PersistableStore.h>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -9,37 +12,25 @@ struct OpdsServer {
|
|||||||
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
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.
|
* Singleton class for storing OPDS server configurations on the SD card.
|
||||||
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
||||||
* and base64-encoded before writing to JSON.
|
* and base64-encoded before writing to JSON.
|
||||||
*/
|
*/
|
||||||
class OpdsServerStore {
|
class OpdsServerStore : public PersistableStore<OpdsServerStore> {
|
||||||
private:
|
private:
|
||||||
static OpdsServerStore instance;
|
|
||||||
std::vector<OpdsServer> servers;
|
std::vector<OpdsServer> servers;
|
||||||
|
|
||||||
static constexpr size_t MAX_SERVERS = 8;
|
static constexpr size_t MAX_SERVERS = 8;
|
||||||
|
|
||||||
OpdsServerStore() = default;
|
OpdsServerStore() = default;
|
||||||
|
|
||||||
friend bool JsonSettingsIO::saveOpds(const OpdsServerStore&, const char*);
|
friend class PersistableStore<OpdsServerStore>;
|
||||||
friend bool JsonSettingsIO::loadOpds(OpdsServerStore&, const char*, bool*);
|
|
||||||
|
|
||||||
public:
|
public:
|
||||||
OpdsServerStore(const OpdsServerStore&) = delete;
|
static const char* getFilePath() { return "/.crosspoint/opds.json"; }
|
||||||
OpdsServerStore& operator=(const OpdsServerStore&) = delete;
|
void toJson(JsonDocument& doc) const;
|
||||||
|
bool fromJson(JsonVariantConst doc);
|
||||||
static OpdsServerStore& getInstance() { return instance; }
|
|
||||||
|
|
||||||
bool saveToFile() const;
|
|
||||||
bool loadFromFile();
|
|
||||||
|
|
||||||
bool addServer(const OpdsServer& server);
|
bool addServer(const OpdsServer& server);
|
||||||
bool updateServer(size_t index, const OpdsServer& server);
|
bool updateServer(size_t index, const OpdsServer& server);
|
||||||
@@ -49,12 +40,6 @@ class OpdsServerStore {
|
|||||||
const OpdsServer* getServer(size_t index) const;
|
const OpdsServer* getServer(size_t index) const;
|
||||||
size_t getCount() const { return servers.size(); }
|
size_t getCount() const { return servers.size(); }
|
||||||
bool hasServers() const { return !servers.empty(); }
|
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()
|
#define OPDS_STORE OpdsServerStore::getInstance()
|
||||||
|
|||||||
+29
-107
@@ -3,23 +3,42 @@
|
|||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
#include <FsHelpers.h>
|
#include <FsHelpers.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <JsonSettingsIO.h>
|
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <Serialization.h>
|
|
||||||
#include <Xtc.h>
|
#include <Xtc.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <iterator>
|
#include <iterator>
|
||||||
|
|
||||||
namespace {
|
void RecentBooksStore::toJson(JsonDocument& doc) const {
|
||||||
constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 3;
|
JsonArray arr = doc["books"].to<JsonArray>();
|
||||||
constexpr char RECENT_BOOKS_FILE_BIN[] = "/.crosspoint/recent.bin";
|
for (const auto& book : recentBooks) {
|
||||||
constexpr char RECENT_BOOKS_FILE_JSON[] = "/.crosspoint/recent.json";
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
constexpr char RECENT_BOOKS_FILE_BAK[] = "/.crosspoint/recent.bin.bak";
|
obj["path"] = book.path;
|
||||||
constexpr int MAX_RECENT_BOOKS = 10;
|
obj["title"] = book.title;
|
||||||
} // namespace
|
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<JsonArrayConst>();
|
||||||
|
recentBooks.reserve(std::min(arr.size(), static_cast<size_t>(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,
|
void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author,
|
||||||
const std::string& coverBmpPath) {
|
const std::string& coverBmpPath) {
|
||||||
@@ -92,11 +111,6 @@ bool RecentBooksStore::pruneMissing() {
|
|||||||
return recentBooks.size() != before;
|
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 {
|
RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
|
||||||
std::string lastBookFileName = "";
|
std::string lastBookFileName = "";
|
||||||
const size_t lastSlash = path.find_last_of('/');
|
const size_t lastSlash = path.find_last_of('/');
|
||||||
@@ -124,95 +138,3 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
|
|||||||
}
|
}
|
||||||
return RecentBook{path, "", "", ""};
|
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<int>(recentBooks.size()));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|||||||
+13
-19
@@ -1,4 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <PersistableStore.h>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -11,24 +14,21 @@ struct RecentBook {
|
|||||||
bool operator==(const RecentBook& other) const { return path == other.path; }
|
bool operator==(const RecentBook& other) const { return path == other.path; }
|
||||||
};
|
};
|
||||||
|
|
||||||
class RecentBooksStore;
|
class RecentBooksStore : public PersistableStore<RecentBooksStore> {
|
||||||
namespace JsonSettingsIO {
|
private:
|
||||||
bool loadRecentBooks(RecentBooksStore& store, const char* json);
|
|
||||||
} // namespace JsonSettingsIO
|
|
||||||
|
|
||||||
class RecentBooksStore {
|
|
||||||
// Static instance
|
|
||||||
static RecentBooksStore instance;
|
|
||||||
|
|
||||||
std::vector<RecentBook> recentBooks;
|
std::vector<RecentBook> recentBooks;
|
||||||
|
|
||||||
friend bool JsonSettingsIO::loadRecentBooks(RecentBooksStore&, const char*);
|
static constexpr int MAX_RECENT_BOOKS = 10;
|
||||||
|
|
||||||
public:
|
RecentBooksStore() = default;
|
||||||
~RecentBooksStore() = default;
|
~RecentBooksStore() = default;
|
||||||
|
|
||||||
// Get singleton instance
|
friend class PersistableStore<RecentBooksStore>;
|
||||||
static RecentBooksStore& getInstance() { return instance; }
|
|
||||||
|
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)
|
// 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,
|
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
|
// Get the count of recent books
|
||||||
int getCount() const { return static_cast<int>(recentBooks.size()); }
|
int getCount() const { return static_cast<int>(recentBooks.size()); }
|
||||||
|
|
||||||
bool saveToFile() const;
|
|
||||||
|
|
||||||
bool loadFromFile();
|
|
||||||
RecentBook getDataFromBook(std::string path) const;
|
RecentBook getDataFromBook(std::string path) const;
|
||||||
|
|
||||||
private:
|
|
||||||
bool loadFromBinaryFile();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper macro to access recent books store
|
// Helper macro to access recent books store
|
||||||
|
|||||||
+26
-86
@@ -1,106 +1,46 @@
|
|||||||
#include "WifiCredentialStore.h"
|
#include "WifiCredentialStore.h"
|
||||||
|
|
||||||
#include <HalStorage.h>
|
|
||||||
#include <JsonSettingsIO.h>
|
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <ObfuscationUtils.h>
|
#include <ObfuscationUtils.h>
|
||||||
#include <Serialization.h>
|
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
// Initialize the static instance
|
void WifiCredentialStore::toJson(JsonDocument& doc) const {
|
||||||
WifiCredentialStore WifiCredentialStore::instance;
|
doc["lastConnectedSsid"] = lastConnectedSsid;
|
||||||
|
|
||||||
namespace {
|
JsonArray arr = doc["credentials"].to<JsonArray>();
|
||||||
// File format version (for binary migration)
|
for (const auto& cred : credentials) {
|
||||||
constexpr uint8_t WIFI_FILE_VERSION = 2;
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
|
obj["ssid"] = cred.ssid;
|
||||||
// File paths
|
obj["password_obf"] = obfuscation::obfuscateToBase64(cred.password);
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // namespace
|
|
||||||
|
|
||||||
bool WifiCredentialStore::saveToFile() const {
|
bool WifiCredentialStore::fromJson(JsonVariantConst doc) {
|
||||||
Storage.mkdir("/.crosspoint");
|
lastConnectedSsid = doc["lastConnectedSsid"] | "";
|
||||||
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);
|
|
||||||
|
|
||||||
|
// 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.clear();
|
||||||
credentials.reserve(std::min<size_t>(count, MAX_NETWORKS));
|
JsonArrayConst arr = doc["credentials"].as<JsonArrayConst>();
|
||||||
for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) {
|
credentials.reserve(std::min(arr.size(), MAX_NETWORKS));
|
||||||
|
bool needsResave = false;
|
||||||
|
|
||||||
|
for (JsonObjectConst obj : arr) {
|
||||||
|
if (credentials.size() >= MAX_NETWORKS) break;
|
||||||
WifiCredential cred;
|
WifiCredential cred;
|
||||||
serialization::readString(file, cred.ssid);
|
cred.ssid = obj["ssid"] | "";
|
||||||
serialization::readString(file, cred.password);
|
cred.password = extractPassword(obj, needsResave);
|
||||||
legacyDeobfuscate(cred.password);
|
|
||||||
credentials.push_back(cred);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <PersistableStore.h>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -7,21 +10,14 @@ struct WifiCredential {
|
|||||||
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
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.
|
* Singleton class for storing WiFi credentials on the SD card.
|
||||||
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
||||||
* and base64-encoded before writing to JSON (not cryptographically secure,
|
* and base64-encoded before writing to JSON (not cryptographically secure,
|
||||||
* but prevents casual reading and ties credentials to the specific device).
|
* but prevents casual reading and ties credentials to the specific device).
|
||||||
*/
|
*/
|
||||||
class WifiCredentialStore {
|
class WifiCredentialStore : public PersistableStore<WifiCredentialStore> {
|
||||||
private:
|
private:
|
||||||
static WifiCredentialStore instance;
|
|
||||||
std::vector<WifiCredential> credentials;
|
std::vector<WifiCredential> credentials;
|
||||||
std::string lastConnectedSsid;
|
std::string lastConnectedSsid;
|
||||||
|
|
||||||
@@ -30,22 +26,12 @@ class WifiCredentialStore {
|
|||||||
// Private constructor for singleton
|
// Private constructor for singleton
|
||||||
WifiCredentialStore() = default;
|
WifiCredentialStore() = default;
|
||||||
|
|
||||||
bool loadFromBinaryFile();
|
friend class PersistableStore<WifiCredentialStore>;
|
||||||
|
|
||||||
friend bool JsonSettingsIO::saveWifi(const WifiCredentialStore&, const char*);
|
|
||||||
friend bool JsonSettingsIO::loadWifi(WifiCredentialStore&, const char*, bool*);
|
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// Delete copy constructor and assignment
|
static const char* getFilePath() { return "/.crosspoint/wifi.json"; }
|
||||||
WifiCredentialStore(const WifiCredentialStore&) = delete;
|
void toJson(JsonDocument& doc) const;
|
||||||
WifiCredentialStore& operator=(const WifiCredentialStore&) = delete;
|
bool fromJson(JsonVariantConst doc);
|
||||||
|
|
||||||
// Get singleton instance
|
|
||||||
static WifiCredentialStore& getInstance() { return instance; }
|
|
||||||
|
|
||||||
// Save/load from SD card
|
|
||||||
bool saveToFile() const;
|
|
||||||
bool loadFromFile();
|
|
||||||
|
|
||||||
// Credential management
|
// Credential management
|
||||||
bool addCredential(const std::string& ssid, const std::string& password);
|
bool addCredential(const std::string& ssid, const std::string& password);
|
||||||
|
|||||||
Reference in New Issue
Block a user