#include "KOReaderSyncClient.h" #include #include #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"; // Small TLS buffers to fit in ESP32-C3's limited heap (~46KB free after WiFi). // KOSync payloads are tiny JSON (<1KB), so 2KB buffers are sufficient. // Default 16KB buffers cause OOM during TLS handshake. constexpr int HTTP_BUF_SIZE = 2048; // Response buffer for reading HTTP body struct ResponseBuffer { char* data = nullptr; int len = 0; int capacity = 0; ~ResponseBuffer() { free(data); } bool ensure(int size) { if (size <= capacity) return true; char* newData = (char*)realloc(data, size); if (!newData) return false; data = newData; capacity = size; return true; } }; // HTTP event handler to collect response body esp_err_t httpEventHandler(esp_http_client_event_t* evt) { auto* buf = static_cast(evt->user_data); if (evt->event_id == HTTP_EVENT_ON_DATA && buf) { if (buf->ensure(buf->len + evt->data_len + 1)) { memcpy(buf->data + buf->len, evt->data, evt->data_len); buf->len += evt->data_len; buf->data[buf->len] = '\0'; } else { LOG_ERR("KOSync", "Response buffer allocation failed (%d bytes)", evt->data_len); } } return ESP_OK; } // Base64 encode for HTTP Basic Auth std::string base64Encode(const std::string& input) { static const char table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; std::string out; out.reserve(((input.size() + 2) / 3) * 4); int val = 0, valb = -6; for (unsigned char c : input) { val = (val << 8) + c; valb += 8; while (valb >= 0) { out.push_back(table[(val >> valb) & 0x3F]); valb -= 6; } } if (valb > -6) out.push_back(table[((val << 8) >> (valb + 8)) & 0x3F]); while (out.size() % 4) out.push_back('='); return out; } // Create configured esp_http_client with small TLS buffers esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf, esp_http_client_method_t method = HTTP_METHOD_GET) { esp_http_client_config_t config = {}; config.url = url; config.event_handler = httpEventHandler; config.user_data = buf; config.method = method; config.timeout_ms = 15000; config.buffer_size = HTTP_BUF_SIZE; config.buffer_size_tx = HTTP_BUF_SIZE; config.crt_bundle_attach = esp_crt_bundle_attach; esp_http_client_handle_t client = esp_http_client_init(&config); if (!client) return nullptr; // KOSync auth headers esp_http_client_set_header(client, "Accept", "application/vnd.koreader.v1+json"); esp_http_client_set_header(client, "x-auth-user", KOREADER_STORE.getUsername().c_str()); esp_http_client_set_header(client, "x-auth-key", KOREADER_STORE.getMd5Password().c_str()); // HTTP Basic Auth for Calibre-Web-Automated compatibility std::string credentials = KOREADER_STORE.getUsername() + ":" + KOREADER_STORE.getPassword(); std::string authHeader = "Basic " + base64Encode(credentials); esp_http_client_set_header(client, "Authorization", authHeader.c_str()); return client; } } // namespace KOReaderSyncClient::Error KOReaderSyncClient::registerUser() { if (!KOREADER_STORE.hasCredentials()) { LOG_DBG("KOSync", "No credentials configured"); return NO_CREDENTIALS; } std::string url = KOREADER_STORE.getBaseUrl() + "/users/create"; LOG_DBG("KOSync", "Registering user: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap()); JsonDocument doc; doc["username"] = KOREADER_STORE.getUsername(); doc["password"] = KOREADER_STORE.getMd5Password(); std::string body; serializeJson(doc, body); LOG_DBG("KOSync", "Register request body: "); ResponseBuffer buf; esp_http_client_handle_t client = createClient(url.c_str(), &buf, HTTP_METHOD_POST); if (!client) return NETWORK_ERROR; esp_http_client_set_header(client, "Content-Type", "application/json"); esp_http_client_set_post_field(client, body.c_str(), body.length()); esp_err_t err = esp_http_client_perform(client); const int httpCode = esp_http_client_get_status_code(client); lastHttpCode = httpCode; esp_http_client_cleanup(client); LOG_DBG("KOSync", "Register response: %d (err: %d) | body: %s", httpCode, err, buf.data ? buf.data : ""); if (err != ESP_OK) { return NETWORK_ERROR; } if (httpCode == 201) { return OK; } else if (httpCode == 200) { // Some server implementations return 200 when the user already exists return USER_EXISTS; } else if (httpCode == 402) { // Both "user already exists" (error 2002) and "registration disabled" (error 2005) // return HTTP 402 on the original kosync server. Distinguish them by body text. std::string lowerBody = buf.data ? buf.data : ""; std::transform(lowerBody.begin(), lowerBody.end(), lowerBody.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); if (lowerBody.find("already") != std::string::npos) { return USER_EXISTS; } return REGISTRATION_DISABLED; } else if (httpCode == 409) { // korrosync returns 409 for existing users return USER_EXISTS; } return SERVER_ERROR; } KOReaderSyncClient::Error KOReaderSyncClient::authenticate() { if (!KOREADER_STORE.hasCredentials()) { LOG_DBG("KOSync", "No credentials configured"); return NO_CREDENTIALS; } std::string url = KOREADER_STORE.getBaseUrl() + "/users/auth"; LOG_DBG("KOSync", "Authenticating: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap()); ResponseBuffer buf; esp_http_client_handle_t client = createClient(url.c_str(), &buf); if (!client) return NETWORK_ERROR; esp_err_t err = esp_http_client_perform(client); const int httpCode = esp_http_client_get_status_code(client); lastHttpCode = httpCode; esp_http_client_cleanup(client); LOG_DBG("KOSync", "Auth response: %d (err: %d)", httpCode, err); if (err != ESP_OK) return NETWORK_ERROR; if (httpCode == 200) return OK; if (httpCode == 401) return AUTH_FAILED; return SERVER_ERROR; } KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash, KOReaderProgress& outProgress) { if (!KOREADER_STORE.hasCredentials()) { LOG_DBG("KOSync", "No credentials configured"); return NO_CREDENTIALS; } std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress/" + documentHash; LOG_DBG("KOSync", "Getting progress: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap()); ResponseBuffer buf; esp_http_client_handle_t client = createClient(url.c_str(), &buf); if (!client) return NETWORK_ERROR; esp_err_t err = esp_http_client_perform(client); const int httpCode = esp_http_client_get_status_code(client); lastHttpCode = httpCode; esp_http_client_cleanup(client); LOG_DBG("KOSync", "Get progress response: %d (err: %d)", httpCode, err); if (err != ESP_OK) return NETWORK_ERROR; if (httpCode == 200 && buf.data) { JsonDocument doc; const DeserializationError error = deserializeJson(doc, buf.data); 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(); LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str()); return OK; } if (httpCode == 401) return AUTH_FAILED; if (httpCode == 404) return NOT_FOUND; return SERVER_ERROR; } KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgress& progress) { if (!KOREADER_STORE.hasCredentials()) { LOG_DBG("KOSync", "No credentials configured"); return NO_CREDENTIALS; } std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress"; LOG_DBG("KOSync", "Updating progress: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap()); // Build JSON body JsonDocument doc; doc["document"] = progress.document; doc["progress"] = progress.progress; doc["percentage"] = progress.percentage; doc["device"] = DEVICE_NAME; doc["device_id"] = DEVICE_ID; std::string body; serializeJson(doc, body); LOG_DBG("KOSync", "Request body: %s", body.c_str()); ResponseBuffer buf; esp_http_client_handle_t client = createClient(url.c_str(), &buf, HTTP_METHOD_PUT); if (!client) return NETWORK_ERROR; esp_http_client_set_header(client, "Content-Type", "application/json"); esp_http_client_set_post_field(client, body.c_str(), body.length()); esp_err_t err = esp_http_client_perform(client); const int httpCode = esp_http_client_get_status_code(client); lastHttpCode = httpCode; esp_http_client_cleanup(client); LOG_DBG("KOSync", "Update progress response: %d (err: %d)", httpCode, err); if (err != ESP_OK) 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 USER_EXISTS: return "Username is already taken"; case REGISTRATION_DISABLED: return "Registration is disabled on this server"; default: return "Unknown error"; } }