#include "KOReaderSyncClient.h" #include #include #include #include #include #include "KOReaderCredentialStore.h" int KOReaderSyncClient::lastHttpCode = 0; namespace { // Device identifier for CrossPoint reader constexpr char DEVICE_NAME[] = "CrossPoint"; constexpr char DEVICE_ID[] = "crosspoint-reader"; // KOSync's TLS-1.3 servers can't be reached through the precompiled system // mbedTLS (TLS 1.3 is stubbed out), so requests run over wolfSSL via // SecureHttpClient. The handshake still needs working heap; gate on it. wolfSSL's // footprint is smaller than mbedTLS's old ~48KB peak, but keep a conservative // floor. Check both total free heap and largest contiguous block so fragmented // heap does not fall through into a failed TLS allocation path. constexpr uint32_t MIN_HEAP_FOR_TLS = 55000; // Apply the shared KOSync auth headers after begin(). x-auth-* is the native // KOSync scheme; Basic auth is added for Calibre-Web-Automated compatibility. void applyAuthHeaders(freeink::SecureHttpClient& http) { http.addHeader("Accept", "application/vnd.koreader.v1+json"); http.addHeader("x-auth-user", KOREADER_STORE.getUsername()); http.addHeader("x-auth-key", KOREADER_STORE.getMd5Password()); const std::string credentials = KOREADER_STORE.getUsername() + ":" + KOREADER_STORE.getPassword(); const String encoded = base64::encode(credentials.c_str()); http.addHeader("Authorization", std::string("Basic ") + encoded.c_str()); } // True when free heap is too low to risk a TLS handshake. bool insufficientHeap() { const uint32_t freeHeap = ESP.getFreeHeap(); const uint32_t maxAllocHeap = ESP.getMaxAllocHeap(); if (freeHeap < MIN_HEAP_FOR_TLS || maxAllocHeap < MIN_HEAP_FOR_TLS) { LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free, %u max alloc (need %u)", freeHeap, maxAllocHeap, MIN_HEAP_FOR_TLS); return true; } return false; } } // namespace KOReaderSyncClient::Error KOReaderSyncClient::authenticate() { lastHttpCode = 0; if (!KOREADER_STORE.hasCredentials()) { LOG_DBG("KOSync", "No credentials configured"); return NO_CREDENTIALS; } const std::string url = KOREADER_STORE.getBaseUrl() + "/users/auth"; LOG_DBG("KOSync", "Authenticating: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap()); if (insufficientHeap()) return LOW_MEMORY; freeink::SecureHttpClient http; http.setInsecure(); if (!http.begin(url)) { LOG_ERR("KOSync", "Bad URL: %s", url.c_str()); return NETWORK_ERROR; } applyAuthHeaders(http); const int httpCode = http.GET(); http.end(); lastHttpCode = httpCode; LOG_DBG("KOSync", "Auth response: %d", httpCode); if (httpCode <= 0) return NETWORK_ERROR; if (httpCode == 200) return OK; if (httpCode == 401) return AUTH_FAILED; return SERVER_ERROR; } KOReaderSyncClient::Error KOReaderSyncClient::createUser() { lastHttpCode = 0; if (!KOREADER_STORE.hasCredentials()) { LOG_DBG("KOSync", "No credentials configured"); return NO_CREDENTIALS; } const std::string url = KOREADER_STORE.getBaseUrl() + "/users/create"; LOG_DBG("KOSync", "Creating account: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap()); if (insufficientHeap()) return LOW_MEMORY; JsonDocument doc; doc["username"] = KOREADER_STORE.getUsername(); doc["password"] = KOREADER_STORE.getMd5Password(); std::string body; serializeJson(doc, body); freeink::SecureHttpClient http; http.setInsecure(); if (!http.begin(url)) { LOG_ERR("KOSync", "Bad URL: %s", url.c_str()); return NETWORK_ERROR; } http.addHeader("Accept", "application/vnd.koreader.v1+json"); http.addHeader("Content-Type", "application/json"); const int httpCode = http.sendRequest("POST", body); http.end(); lastHttpCode = httpCode; LOG_DBG("KOSync", "Create user response: %d", httpCode); if (httpCode <= 0) return NETWORK_ERROR; if (httpCode == 200 || httpCode == 201) return OK; if (httpCode == 402) return USER_EXISTS; return SERVER_ERROR; } KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash, KOReaderProgress& outProgress) { lastHttpCode = 0; if (!KOREADER_STORE.hasCredentials()) { LOG_DBG("KOSync", "No credentials configured"); return NO_CREDENTIALS; } const std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress/" + documentHash; LOG_DBG("KOSync", "Getting progress: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap()); if (insufficientHeap()) return LOW_MEMORY; freeink::SecureHttpClient http; http.setInsecure(); if (!http.begin(url)) { LOG_ERR("KOSync", "Bad URL: %s", url.c_str()); return NETWORK_ERROR; } applyAuthHeaders(http); const int httpCode = http.GET(); lastHttpCode = httpCode; LOG_DBG("KOSync", "Get progress response: %d", httpCode); if (httpCode <= 0) { http.end(); return NETWORK_ERROR; } if (httpCode == 200) { JsonDocument doc; const DeserializationError error = deserializeJson(doc, http.getString().c_str()); http.end(); if (error) { LOG_ERR("KOSync", "JSON parse failed: %s", error.c_str()); return JSON_ERROR; } outProgress.document = documentHash; outProgress.progress = doc["progress"].as(); outProgress.percentage = doc["percentage"].as(); outProgress.device = doc["device"].as(); outProgress.deviceId = doc["device_id"].as(); outProgress.timestamp = doc["timestamp"].as(); // Extended crosspoint-sync field; absent on plain kosync servers. outProgress.position.reset(); const JsonObjectConst pos = doc["position"].as(); if (!pos.isNull()) { KOReaderRichPosition rich; rich.pctQ = pos["pctQ"].as(); rich.spineIndex = pos["spine"].as(); rich.pageNumber = pos["page"].as(); const uint16_t pages = pos["pages"].as(); rich.totalPages = pages > 0 ? pages : 1; const uint16_t para = pos["para"].as(); if (para > 0) rich.paragraphIndex = para; rich.xpath = pos["xpath"].as() ? pos["xpath"].as() : ""; LOG_DBG("KOSync", "Got rich position: spine=%u page=%u/%u para=%u", rich.spineIndex, rich.pageNumber, rich.totalPages, para); outProgress.position = std::move(rich); } LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str()); return OK; } http.end(); if (httpCode == 401) return AUTH_FAILED; if (httpCode == 404) return NOT_FOUND; return SERVER_ERROR; } KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgress& progress) { lastHttpCode = 0; if (!KOREADER_STORE.hasCredentials()) { LOG_DBG("KOSync", "No credentials configured"); return NO_CREDENTIALS; } const std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress"; LOG_DBG("KOSync", "Updating progress: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap()); if (insufficientHeap()) return LOW_MEMORY; // Build JSON body JsonDocument doc; doc["document"] = progress.document; if (progress.metadata.has_value()) { auto meta = doc["metadata"].to(); meta["filename"] = progress.metadata->filename; meta["title"] = progress.metadata->title; meta["authors"] = progress.metadata->authors; } doc["progress"] = progress.progress; doc["percentage"] = progress.percentage; doc["device"] = DEVICE_NAME; doc["device_id"] = DEVICE_ID; if (progress.position.has_value()) { // Extended crosspoint-sync field; kosync servers ignore unknown keys. const auto& p = *progress.position; auto pos = doc["position"].to(); pos["pctQ"] = p.pctQ; pos["spine"] = p.spineIndex; pos["page"] = p.pageNumber; pos["pages"] = p.totalPages; if (p.paragraphIndex.has_value()) pos["para"] = *p.paragraphIndex; // Server rejects the whole position object if xpath exceeds 120 bytes. if (!p.xpath.empty() && p.xpath.size() <= 120) pos["xpath"] = p.xpath; } std::string body; serializeJson(doc, body); LOG_DBG("KOSync", "Request body: %s", body.c_str()); freeink::SecureHttpClient http; http.setInsecure(); if (!http.begin(url)) { LOG_ERR("KOSync", "Bad URL: %s", url.c_str()); return NETWORK_ERROR; } applyAuthHeaders(http); http.addHeader("Content-Type", "application/json"); const int httpCode = http.sendRequest("PUT", body); http.end(); lastHttpCode = httpCode; LOG_DBG("KOSync", "Update progress response: %d", httpCode); if (httpCode <= 0) return NETWORK_ERROR; if (httpCode == 200 || httpCode == 202) return OK; if (httpCode == 401) return AUTH_FAILED; return SERVER_ERROR; } const char* KOReaderSyncClient::errorString(Error error) { switch (error) { case OK: return "Success"; case NO_CREDENTIALS: return "No credentials configured"; case NETWORK_ERROR: return "Network error"; case AUTH_FAILED: return "Authentication failed"; case SERVER_ERROR: return "Server error (try again later)"; case JSON_ERROR: return "JSON parse error"; case NOT_FOUND: return "No progress found"; case LOW_MEMORY: return "Not enough memory for sync — please retry"; default: return "Unknown error"; } }