chore: Refactor stores to use PersistableStore CRTP template (#2464)
This commit is contained in:
@@ -1,118 +1,43 @@
|
||||
#include "KOReaderCredentialStore.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <MD5Builder.h>
|
||||
#include <ObfuscationUtils.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#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<uint8_t>(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<uint8_t>(DocumentMatchMethod::BINARY)) {
|
||||
setMatchMethod(static_cast<DocumentMatchMethod>(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<DocumentMatchMethod>(method);
|
||||
} else {
|
||||
matchMethod = DocumentMatchMethod::FILENAME;
|
||||
}
|
||||
|
||||
LOG_DBG("KRS", "Loaded KOReader credentials from binary for user: %s", username.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#pragma once
|
||||
#include <ArduinoJson.h>
|
||||
#include <PersistableStore.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
@@ -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<KOReaderCredentialStore> {
|
||||
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<KOReaderCredentialStore>;
|
||||
|
||||
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);
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user