Files
Crosspoint/lib/KOReaderSync/KOReaderSyncClient.cpp
Justin Mitchell b1d037569b feat: Add kosync user registration and switch to crosspoint-sync server (#2587)
Implements createUser() endpoint to allow account creation via the
KOSync protocol. Changes default sync server from sync.koreader.rocks to
sync.crosspointreader.com with migration logic to preserve existing
users' server settings. Extends sync protocol to include position data
(spine index, page numbers, xpath) that crosspoint-sync supports while
remaining compatible with standard kosync servers.
2026-07-18 14:50:51 -04:00

276 lines
9.3 KiB
C++

#include "KOReaderSyncClient.h"
#include <ArduinoJson.h>
#include <Logging.h>
#include <SecureHttpClient.h>
#include <base64.h>
#include <string>
#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<std::string>();
outProgress.percentage = doc["percentage"].as<float>();
outProgress.device = doc["device"].as<std::string>();
outProgress.deviceId = doc["device_id"].as<std::string>();
outProgress.timestamp = doc["timestamp"].as<int64_t>();
// Extended crosspoint-sync field; absent on plain kosync servers.
outProgress.position.reset();
const JsonObjectConst pos = doc["position"].as<JsonObjectConst>();
if (!pos.isNull()) {
KOReaderRichPosition rich;
rich.pctQ = pos["pctQ"].as<uint32_t>();
rich.spineIndex = pos["spine"].as<uint16_t>();
rich.pageNumber = pos["page"].as<uint16_t>();
const uint16_t pages = pos["pages"].as<uint16_t>();
rich.totalPages = pages > 0 ? pages : 1;
const uint16_t para = pos["para"].as<uint16_t>();
if (para > 0) rich.paragraphIndex = para;
rich.xpath = pos["xpath"].as<const char*>() ? pos["xpath"].as<const char*>() : "";
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<JsonObject>();
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<JsonObject>();
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";
}
}