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
264 lines
7.6 KiB
C++
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); });
|
|
});
|
|
}
|