Files
crosspoint-reader-mod/src/OpdsServerStore.cpp
cottongin 7eaced602f feat: add post-download prompt with open book / back to listing options
After an OPDS download completes, show a prompt screen instead of
immediately returning to the catalog. The user can choose to open the
book for reading or go back to the listing. A live countdown (5s, fast
refresh) auto-selects the configured default; any button press cancels
the timer. A per-server "After Download" setting controls the default
action (back to listing for backward compatibility).

Made-with: Cursor
2026-03-02 15:27:53 -05:00

264 lines
7.6 KiB
C++

#include "OpdsServerStore.h"
#include <ArduinoJson.h>
#include <HalStorage.h>
#include <Logging.h>
#include <base64.h>
#include <esp_mac.h>
#include <mbedtls/base64.h>
#include <algorithm>
#include <cstring>
#include "CrossPointSettings.h"
OpdsServerStore OpdsServerStore::instance;
namespace {
constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json";
constexpr size_t HW_KEY_LEN = 6;
const uint8_t* getHwKey() {
static uint8_t key[HW_KEY_LEN] = {};
static bool initialized = false;
if (!initialized) {
esp_efuse_mac_get_default(key);
initialized = true;
}
return key;
}
void xorTransform(std::string& data) {
const uint8_t* key = getHwKey();
for (size_t i = 0; i < data.size(); i++) {
data[i] ^= key[i % HW_KEY_LEN];
}
}
String obfuscateToBase64(const std::string& plaintext) {
if (plaintext.empty()) return "";
std::string temp = plaintext;
xorTransform(temp);
return base64::encode(reinterpret_cast<const uint8_t*>(temp.data()), temp.size());
}
std::string deobfuscateFromBase64(const char* encoded, bool* ok) {
if (encoded == nullptr || encoded[0] == '\0') {
if (ok) *ok = false;
return "";
}
if (ok) *ok = true;
size_t encodedLen = strlen(encoded);
size_t decodedLen = 0;
int ret = mbedtls_base64_decode(nullptr, 0, &decodedLen, reinterpret_cast<const unsigned char*>(encoded), encodedLen);
if (ret != 0 && ret != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) {
LOG_ERR("OPS", "Base64 decode size query failed (ret=%d)", ret);
if (ok) *ok = false;
return "";
}
std::string result(decodedLen, '\0');
ret = mbedtls_base64_decode(reinterpret_cast<unsigned char*>(&result[0]), decodedLen, &decodedLen,
reinterpret_cast<const unsigned char*>(encoded), encodedLen);
if (ret != 0) {
LOG_ERR("OPS", "Base64 decode failed (ret=%d)", ret);
if (ok) *ok = false;
return "";
}
result.resize(decodedLen);
xorTransform(result);
return result;
}
} // namespace
bool OpdsServerStore::saveToFile() const {
Storage.mkdir("/.crosspoint");
JsonDocument doc;
JsonArray arr = doc["servers"].to<JsonArray>();
for (const auto& server : servers) {
JsonObject obj = arr.add<JsonObject>();
obj["name"] = server.name;
obj["url"] = server.url;
obj["username"] = server.username;
obj["password_obf"] = obfuscateToBase64(server.password);
obj["download_path"] = server.downloadPath;
obj["sort_order"] = server.sortOrder;
obj["after_download"] = server.afterDownloadAction;
}
String json;
serializeJson(doc, json);
return Storage.writeFile(OPDS_FILE_JSON, json);
}
bool OpdsServerStore::loadFromFile() {
if (Storage.exists(OPDS_FILE_JSON)) {
String json = Storage.readFile(OPDS_FILE_JSON);
if (!json.isEmpty()) {
JsonDocument doc;
auto error = deserializeJson(doc, json.c_str());
if (error) {
LOG_ERR("OPS", "JSON parse error: %s", error.c_str());
return false;
}
servers.clear();
bool needsResave = false;
JsonArray arr = doc["servers"].as<JsonArray>();
for (JsonObject obj : arr) {
if (servers.size() >= MAX_SERVERS) break;
OpdsServer server;
server.name = obj["name"] | std::string("");
server.url = obj["url"] | std::string("");
server.username = obj["username"] | std::string("");
bool ok = false;
server.password = deobfuscateFromBase64(obj["password_obf"] | "", &ok);
if (!ok || server.password.empty()) {
server.password = obj["password"] | std::string("");
if (!server.password.empty()) needsResave = true;
}
server.downloadPath = obj["download_path"] | std::string("/");
server.sortOrder = obj["sort_order"] | 0;
server.afterDownloadAction = obj["after_download"] | 0;
if (server.sortOrder == 0) needsResave = true;
servers.push_back(std::move(server));
}
// Assign sequential sort orders to servers loaded without one
bool anyZero = false;
for (const auto& s : servers) {
if (s.sortOrder == 0) {
anyZero = true;
break;
}
}
if (anyZero) {
for (size_t i = 0; i < servers.size(); i++) {
if (servers[i].sortOrder == 0) {
servers[i].sortOrder = static_cast<int>(i) + 1;
}
}
}
sortServers();
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size());
if (needsResave) {
LOG_DBG("OPS", "Resaving JSON with sort_order / obfuscated passwords");
saveToFile();
}
return true;
}
}
// 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;
server.sortOrder = 1;
servers.push_back(std::move(server));
if (saveToFile()) {
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;
}
servers.clear();
return false;
}
bool OpdsServerStore::addServer(const OpdsServer& server) {
if (servers.size() >= MAX_SERVERS) {
LOG_DBG("OPS", "Cannot add more servers, limit of %zu reached", MAX_SERVERS);
return false;
}
servers.push_back(server);
if (servers.back().sortOrder == 0) {
int maxOrder = 0;
for (size_t i = 0; i + 1 < servers.size(); i++) {
maxOrder = std::max(maxOrder, servers[i].sortOrder);
}
servers.back().sortOrder = maxOrder + 1;
}
LOG_DBG("OPS", "Added server: %s (order=%d)", server.name.c_str(), servers.back().sortOrder);
sortServers();
return saveToFile();
}
bool OpdsServerStore::updateServer(size_t index, const OpdsServer& server) {
if (index >= servers.size()) {
return false;
}
servers[index] = server;
sortServers();
LOG_DBG("OPS", "Updated server: %s (order=%d)", server.name.c_str(), server.sortOrder);
return saveToFile();
}
bool OpdsServerStore::removeServer(size_t index) {
if (index >= servers.size()) {
return false;
}
LOG_DBG("OPS", "Removed server: %s", servers[index].name.c_str());
servers.erase(servers.begin() + static_cast<ptrdiff_t>(index));
return saveToFile();
}
const OpdsServer* OpdsServerStore::getServer(size_t index) const {
if (index >= servers.size()) {
return nullptr;
}
return &servers[index];
}
bool OpdsServerStore::moveServer(size_t index, int direction) {
if (index >= servers.size()) return false;
size_t target;
if (direction < 0) {
if (index == 0) return false;
target = index - 1;
} else {
if (index + 1 >= servers.size()) return false;
target = index + 1;
}
std::swap(servers[index].sortOrder, servers[target].sortOrder);
sortServers();
return saveToFile();
}
void OpdsServerStore::sortServers() {
std::sort(servers.begin(), servers.end(), [](const OpdsServer& a, const OpdsServer& b) {
if (a.sortOrder != b.sortOrder) return a.sortOrder < b.sortOrder;
const auto& nameA = a.name.empty() ? a.url : a.name;
const auto& nameB = b.name.empty() ? b.url : b.name;
return std::lexicographical_compare(nameA.begin(), nameA.end(), nameB.begin(), nameB.end(),
[](char ca, char cb) { return tolower(ca) < tolower(cb); });
});
}