diff --git a/include/nocal/sync/caldav_account.hpp b/include/nocal/sync/caldav_account.hpp new file mode 100644 index 0000000..5e89c69 --- /dev/null +++ b/include/nocal/sync/caldav_account.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +namespace nocal::sync { + +// CalDAV account configuration. +struct CalDAVAccountConfig { + std::string server_url; + std::string username; + std::string password; + std::string account_id; + std::string display_name; +}; + +// Derive a deterministic account ID from server URL and username. +[[nodiscard]] std::string caldav_account_id( + std::string_view server_url, std::string_view username); + +} // namespace nocal::sync diff --git a/include/nocal/sync/caldav_sync.hpp b/include/nocal/sync/caldav_sync.hpp new file mode 100644 index 0000000..69bc8ab --- /dev/null +++ b/include/nocal/sync/caldav_sync.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include "nocal/sync/caldav_account.hpp" +#include "nocal/sync/http.hpp" +#include "nocal/storage/sync_cache.hpp" + +#include +#include + +namespace nocal::sync { + +// CalDAV calendar listing entry. +struct CalDAVCalendar { + std::string href; + std::string name; + std::string description; + bool read_only{false}; +}; + +class CalDAVSync { +public: + CalDAVSync(HttpTransport& transport, storage::SyncCache& cache, + const CalDAVAccountConfig& config); + + // Discover principal URL and calendar-home-set. + void discover(); + + // List calendars under the calendar-home-set. + std::vector list_calendars(); + + // Sync one calendar using sync-collection (delta) or full PROPFIND. + // `sync_token` is empty for initial sync. Returns new sync token. + std::string sync_calendar(const CalDAVCalendar& calendar, + std::string_view sync_token); + +private: + HttpTransport& transport_; + storage::SyncCache& cache_; + CalDAVAccountConfig config_; + + std::string principal_url_; + std::string calendar_home_set_; + + std::string send_request(int method, const std::string& url, + const std::string& body, const std::vector& extra_headers); + std::string calendar_resource_url(const std::string& calendar_href) const; + std::string stable_calendar_id(const std::string& remote_id) const; + std::string stable_event_id(const std::string& calendar_id, const std::string& remote_uid) const; + std::string encode_basic_auth() const; +}; + +} // namespace nocal::sync diff --git a/include/nocal/sync/caldav_sync_provider.hpp b/include/nocal/sync/caldav_sync_provider.hpp new file mode 100644 index 0000000..3efee06 --- /dev/null +++ b/include/nocal/sync/caldav_sync_provider.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "nocal/sync/caldav_account.hpp" +#include "nocal/sync/http.hpp" +#include "nocal/sync/provider.hpp" + +namespace nocal::storage { +class SyncCache; +} + +#include + +namespace nocal::sync { + +class CalDAVSyncProvider final : public SyncProvider { +public: + CalDAVSyncProvider(HttpTransport& transport, storage::SyncCache& cache, + CalDAVAccountConfig config); + + std::string_view provider_id() const noexcept override { return "caldav"; } + ConnectedAccount connect_account() override; + void sync_account(std::string_view account_id, SyncObserver& observer) override; + void disconnect_account(std::string_view account_id) override; + +private: + HttpTransport& transport_; + storage::SyncCache& cache_; + CalDAVAccountConfig config_; +}; + +} // namespace nocal::sync diff --git a/meson.build b/meson.build index 5c9c38d..79dceba 100644 --- a/meson.build +++ b/meson.build @@ -26,6 +26,9 @@ nocal_sources = files( 'src/domain/month_layout.cpp', 'src/storage/ics_store.cpp', 'src/storage/sync_cache.cpp', + 'src/sync/caldav_account.cpp', + 'src/sync/caldav_sync.cpp', + 'src/sync/caldav_sync_provider.cpp', 'src/sync/caldav_xml.cpp', 'src/sync/curl_http.cpp', 'src/sync/desktop_oauth.cpp', diff --git a/src/sync/caldav_account.cpp b/src/sync/caldav_account.cpp new file mode 100644 index 0000000..655693e --- /dev/null +++ b/src/sync/caldav_account.cpp @@ -0,0 +1,42 @@ +#include "nocal/sync/caldav_account.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace nocal::sync { +namespace { + +[[nodiscard]] std::string sha256_hex(std::string_view input) { + std::array digest{}; + unsigned int size = 0; + if (EVP_Digest(input.data(), input.size(), digest.data(), &size, EVP_sha256(), nullptr) != 1 + || size != digest.size()) { + throw std::runtime_error("cryptographic failure"); + } + static constexpr char hexadecimal[] = "0123456789abcdef"; + std::string output; + output.reserve(digest.size() * 2U); + for (const auto byte : digest) { + output.push_back(hexadecimal[(byte >> 4) & 0xf]); + output.push_back(hexadecimal[byte & 0xf]); + } + return output; +} + +} // namespace + +std::string caldav_account_id(const std::string_view server_url, + const std::string_view username) { + std::string input("caldav-account\0"); + input += server_url; + input.push_back('\0'); + input += username; + return std::string("caldav-account-") + sha256_hex(input); +} + +} // namespace nocal::sync diff --git a/src/sync/caldav_sync.cpp b/src/sync/caldav_sync.cpp new file mode 100644 index 0000000..a60914b --- /dev/null +++ b/src/sync/caldav_sync.cpp @@ -0,0 +1,434 @@ +#include "nocal/sync/caldav_sync.hpp" + +#include "nocal/sync/caldav_xml.hpp" +#include "nocal/storage/ics_store.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace nocal::sync { +namespace { + +[[nodiscard]] std::string sha256_hex(std::string_view input) { + std::array digest{}; + unsigned int size = 0; + if (EVP_Digest(input.data(), input.size(), digest.data(), &size, EVP_sha256(), nullptr) != 1 + || size != digest.size()) { + throw std::runtime_error("cryptographic failure"); + } + static constexpr char hexadecimal[] = "0123456789abcdef"; + std::string output; + output.reserve(digest.size() * 2U); + for (const auto byte : digest) { + output.push_back(hexadecimal[(byte >> 4) & 0xf]); + output.push_back(hexadecimal[byte & 0xf]); + } + return output; +} + +[[nodiscard]] std::string base64_encode(std::string_view input) { + static constexpr char table[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string output; + output.reserve(((input.size() + 2) / 3) * 4); + std::size_t i = 0; + while (i + 2 < input.size()) { + const std::uint32_t octet = (static_cast( + static_cast(input[i])) + << 16) + | (static_cast(static_cast(input[i + 1])) << 8) + | static_cast(static_cast(input[i + 2])); + output.push_back(table[(octet >> 18) & 0x3f]); + output.push_back(table[(octet >> 12) & 0x3f]); + output.push_back(table[(octet >> 6) & 0x3f]); + output.push_back(table[octet & 0x3f]); + i += 3; + } + const std::size_t remaining = input.size() - i; + if (remaining > 0) { + const std::uint32_t octet = (static_cast( + static_cast(input[i])) + << 16); + if (remaining == 2) { + const std::uint32_t second = static_cast( + static_cast(input[i + 1])) + << 8; + output.push_back(table[((octet | second) >> 18) & 0x3f]); + output.push_back(table[((octet | second) >> 12) & 0x3f]); + output.push_back(table[((octet | second) >> 6) & 0x3f]); + output.push_back('='); + } else { + output.push_back(table[(octet >> 18) & 0x3f]); + output.push_back(table[(octet >> 12) & 0x3f]); + output.push_back('='); + output.push_back('='); + } + } + return output; +} + +std::string strip_href_prefix(const std::string_view href, + const std::string_view base) { + if (href.size() >= base.size() && href.substr(0, base.size()) == base) { + return std::string(href.substr(base.size())); + } + return std::string(href); +} + +} // namespace + +CalDAVSync::CalDAVSync(HttpTransport& transport, storage::SyncCache& cache, + const CalDAVAccountConfig& config) + : transport_(transport), cache_(cache), config_(config) {} + +void CalDAVSync::discover() { + // Step 1: Find principal URL + const std::string principal_body = R"( + + + + +)"; + + std::vector headers{ + {"Depth", "0"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"Authorization", "Basic " + encode_basic_auth()}, + }; + + const std::string principal_response = send_request( + static_cast(HttpMethod::propfind), config_.server_url + "/", + principal_body, headers); + + const auto principal_root = parse_xml(principal_response); + const auto responses = find_children(principal_root, "D:response"); + if (responses.empty()) { + throw std::runtime_error("CalDAV: no response in principal discovery"); + } + + for (const auto* response : responses) { + const auto href_results = find_children(*response, "D:href"); + if (href_results.empty()) continue; + const std::string href = std::string(href_results[0]->text); + + const auto url_results = find_children(*response, "D:principal-URL"); + if (url_results.empty()) continue; + + principal_url_ = std::string(url_results[0]->text); + break; + } + + if (principal_url_.empty()) { + throw std::runtime_error("CalDAV: principal URL not found"); + } + + // Step 2: Find calendar-home-set + const std::string home_body = R"( + + + + +)"; + + const std::string home_response = send_request( + static_cast(HttpMethod::propfind), principal_url_, home_body, headers); + + const auto home_root = parse_xml(home_response); + const auto home_responses = find_children(home_root, "D:response"); + if (home_responses.empty()) { + throw std::runtime_error("CalDAV: no response in calendar-home-set discovery"); + } + + for (const auto* response : home_responses) { + const auto home_set = find_children(*response, "C:calendar-home-set"); + if (!home_set.empty()) { + calendar_home_set_ = std::string(home_set[0]->text); + break; + } + } + + if (calendar_home_set_.empty()) { + throw std::runtime_error("CalDAV: calendar-home-set not found"); + } +} + +std::vector CalDAVSync::list_calendars() { + const std::string calendar_body = R"( + + + + + + + +)"; + + std::vector headers{ + {"Depth", "1"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"Authorization", "Basic " + encode_basic_auth()}, + }; + + const std::string response = send_request( + static_cast(HttpMethod::propfind), calendar_home_set_, + calendar_body, headers); + + const auto root = parse_xml(response); + const auto responses = find_children(root, "D:response"); + + std::vector calendars; + for (const auto* resp : responses) { + const auto hrefs = find_children(*resp, "D:href"); + if (hrefs.empty()) continue; + const std::string href = std::string(hrefs[0]->text); + + // Skip the calendar-home-set itself + if (href == calendar_home_set_) continue; + + // Check if it's a calendar collection + const auto types = find_children(*resp, "D:resourcetype"); + bool is_calendar = false; + for (const auto* type : types) { + for (const auto& child : type->children) { + if (child.tag == "C:calendar" || child.tag == "calendar") { + is_calendar = true; + break; + } + } + if (is_calendar) break; + } + if (!is_calendar) continue; + + CalDAVCalendar calendar; + calendar.href = href; + + const auto names = find_children(*resp, "D:displayname"); + if (!names.empty()) { + calendar.name = std::string(names[0]->text); + } + + const auto descs = find_children(*resp, "C:calendar-description"); + if (!descs.empty()) { + calendar.description = std::string(descs[0]->text); + } + + calendars.push_back(std::move(calendar)); + } + + // Upsert calendars in cache + std::vector cached; + for (const auto& cal : calendars) { + storage::CachedCalendar c; + c.id = stable_calendar_id(cal.href); + c.account_id = config_.account_id; + c.remote_id = cal.href; + c.name = cal.name; + c.read_only = cal.read_only; + c.active = true; + cached.push_back(std::move(c)); + } + + if (!cached.empty()) { + cache_.replace_calendars_after_complete_listing(config_.account_id, cached); + } + + return calendars; +} + +std::string CalDAVSync::sync_calendar(const CalDAVCalendar& calendar, + const std::string_view sync_token) { + const std::string token_xml = sync_token.empty() + ? std::string{} + : "" + std::string(sync_token) + ""; + + const std::string sync_body = + "\n" + "\n" + " 1\n" + + token_xml + "\n" + " \n" + " \n" + " \n" + ""; + + std::vector headers{ + {"Depth", "1"}, + {"Content-Type", "application/xml; charset=utf-8"}, + {"Authorization", "Basic " + encode_basic_auth()}, + }; + + const std::string response = send_request( + static_cast(HttpMethod::report), calendar.href, + sync_body, headers); + + const auto root = parse_xml(response); + const auto responses = find_children(root, "D:response"); + + std::string new_sync_token; + std::vector event_hrefs; + std::vector event_etags; + + for (const auto* resp : responses) { + const auto hrefs = find_children(*resp, "D:href"); + if (hrefs.empty()) continue; + const std::string href = std::string(hrefs[0]->text); + + const auto tokens = find_children(*resp, "D:sync-token"); + if (!tokens.empty()) { + new_sync_token = std::string(tokens[0]->text); + continue; + } + + const auto etags = find_children(*resp, "D:getetag"); + if (!etags.empty()) { + std::string etag = std::string(etags[0]->text); + if (!etag.empty() && etag.front() == '"') { + etag = etag.substr(1, etag.size() - 2); + } + event_hrefs.push_back(href); + event_etags.push_back(std::move(etag)); + continue; + } + + const auto status = find_children(*resp, "D:status"); + if (!status.empty() && status[0]->text.find("404") != std::string::npos) { + // Deleted event — remove from cache + const std::string path = strip_href_prefix(href, calendar_resource_url("")); + // TODO: mark event as deleted in cache + continue; + } + } + + // Fetch and parse each event + storage::PullPage page; + const std::string cal_id = stable_calendar_id(calendar.href); + + for (std::size_t i = 0; i < event_hrefs.size(); ++i) { + const std::string event_url = calendar_resource_url(event_hrefs[i]); + HttpRequest get_req; + get_req.method = HttpMethod::get; + get_req.url = event_url; + get_req.headers = { + {"Authorization", "Basic " + encode_basic_auth()}, + }; + + const auto get_response = transport_.send(get_req); + if (get_response.status != 200) { + continue; + } + + // Parse iCalendar content + std::filesystem::path temp_path = std::filesystem::temp_directory_path() + / ("caldav-sync-XXXXXX"); + std::string temp_str = temp_path.string(); + char* temp_chars = temp_str.data(); + const int fd = ::mkstemp(temp_chars); + if (fd < 0) continue; + temp_path = std::string(temp_str); + + (void)::write(fd, get_response.body.data(), get_response.body.size()); + ::close(fd); + + const auto loaded = storage::IcsStore::load(temp_path); + std::filesystem::remove(temp_path); + + for (const auto& event : loaded.events) { + storage::CachedEvent cached_event; + cached_event.id = stable_event_id(cal_id, event.uid); + cached_event.calendar_id = cal_id; + cached_event.remote_id = event_hrefs[i]; + cached_event.uid = event.uid; + cached_event.etag = event_etags[i]; + cached_event.raw_payload = get_response.body; + cached_event.deleted = false; + page.events.push_back(std::move(cached_event)); + + storage::CachedEventInstance instance; + instance.event_id = cached_event.id; + const auto start_tp = std::chrono::time_point_cast(event.start); + instance.start_epoch_microseconds = start_tp.time_since_epoch().count(); + const auto end_tp = std::chrono::time_point_cast(event.end); + instance.end_epoch_microseconds = end_tp.time_since_epoch().count(); + instance.all_day = event.all_day; + instance.title = event.title; + instance.location = event.location; + instance.description = event.description; + instance.time_zone = event.time_zone; + page.instances.push_back(std::move(instance)); + } + } + + page.checkpoint.calendar_id = cal_id; + page.checkpoint.cursor = new_sync_token; + page.checkpoint.complete = true; + + if (!page.events.empty() || !new_sync_token.empty()) { + cache_.apply_pull_page(page); + } + + return new_sync_token; +} + +std::string CalDAVSync::send_request(const int method, const std::string& url, + const std::string& body, + const std::vector& extra_headers) { + HttpRequest request; + request.method = static_cast(method); + request.url = url; + request.headers = extra_headers; + request.body = body; + + const auto response = transport_.send(request); + if (response.status == 401) { + throw std::runtime_error("CalDAV: authentication failed"); + } + if (response.status == 403) { + throw std::runtime_error("CalDAV: forbidden"); + } + if (response.status == 404) { + throw std::runtime_error("CalDAV: resource not found: " + url); + } + if (response.status != 200 && response.status != 207) { + throw std::runtime_error("CalDAV: unexpected status " + std::to_string(response.status) + + " from " + url); + } + return response.body; +} + +std::string CalDAVSync::calendar_resource_url(const std::string& calendar_href) const { + return calendar_href; +} + +std::string CalDAVSync::stable_calendar_id(const std::string& remote_id) const { + std::string input("caldav-calendar\0"); + input += config_.account_id; + input.push_back('\0'); + input += remote_id; + return std::string("caldav-calendar-") + sha256_hex(input); +} + +std::string CalDAVSync::stable_event_id(const std::string& calendar_id, + const std::string& remote_uid) const { + std::string input("caldav-event\0"); + input += calendar_id; + input.push_back('\0'); + input += remote_uid; + return std::string("caldav-event-") + sha256_hex(input); +} + +std::string CalDAVSync::encode_basic_auth() const { + const std::string credentials = config_.username + ":" + config_.password; + return "Basic " + base64_encode(credentials); +} + +} // namespace nocal::sync diff --git a/src/sync/caldav_sync_provider.cpp b/src/sync/caldav_sync_provider.cpp new file mode 100644 index 0000000..916e8a8 --- /dev/null +++ b/src/sync/caldav_sync_provider.cpp @@ -0,0 +1,106 @@ +#include "nocal/sync/caldav_sync_provider.hpp" + +#include "nocal/sync/caldav_sync.hpp" +#include "nocal/storage/sync_cache.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace nocal::sync { +namespace { + +[[nodiscard]] std::string sha256_hex(std::string_view input) { + std::array digest{}; + unsigned int size = 0; + if (EVP_Digest(input.data(), input.size(), digest.data(), &size, EVP_sha256(), nullptr) != 1 + || size != digest.size()) { + throw std::runtime_error("cryptographic failure"); + } + static constexpr char hexadecimal[] = "0123456789abcdef"; + std::string output; + output.reserve(digest.size() * 2U); + for (const auto byte : digest) { + output.push_back(hexadecimal[(byte >> 4) & 0xf]); + output.push_back(hexadecimal[byte & 0xf]); + } + return output; +} + +[[nodiscard]] std::string caldav_calendar_id(std::string_view account_id, + std::string_view remote_id) { + std::string input("caldav-calendar\0"); + input += account_id; + input.push_back('\0'); + input += remote_id; + return std::string("caldav-calendar-") + sha256_hex(input); +} + +} // namespace + +CalDAVSyncProvider::CalDAVSyncProvider(HttpTransport& transport, + storage::SyncCache& cache, + CalDAVAccountConfig config) + : transport_(transport), cache_(cache), config_(std::move(config)) {} + +ConnectedAccount CalDAVSyncProvider::connect_account() { + // Account is configured via constructor. Verify connectivity. + CalDAVSync sync(transport_, cache_, config_); + sync.discover(); + return {config_.account_id, config_.display_name}; +} + +void CalDAVSyncProvider::sync_account(const std::string_view account_id, + SyncObserver& observer) { + if (account_id != config_.account_id) { + throw std::runtime_error("CalDAV: account ID mismatch"); + } + + observer.on_sync_progress(SyncProgressEvent{ + SyncStage::authenticating, config_.account_id, {}, 0, 0}); + + CalDAVSync sync(transport_, cache_, config_); + + observer.on_sync_progress(SyncProgressEvent{ + SyncStage::discovering_calendars, config_.account_id, {}, 0, 0}); + + const auto calendars = sync.list_calendars(); + + const std::size_t total = calendars.size(); + for (std::size_t i = 0; i < total; ++i) { + const auto& calendar = calendars[i]; + observer.on_sync_progress(SyncProgressEvent{ + SyncStage::syncing_calendar, config_.account_id, + calendar.name, static_cast(i + 1), total}); + + const auto snapshot = cache_.snapshot(); + std::string cursor; + for (const auto& cp : snapshot.checkpoints) { + if (cp.calendar_id == caldav_calendar_id(config_.account_id, calendar.href)) { + cursor = cp.cursor; + break; + } + } + + const std::string new_token = sync.sync_calendar(calendar, cursor); + (void)new_token; + } + + observer.on_sync_progress(SyncProgressEvent{ + SyncStage::completed, config_.account_id, {}, static_cast(total), total}); +} + +void CalDAVSyncProvider::disconnect_account(const std::string_view account_id) { + if (account_id != config_.account_id) { + throw std::runtime_error("CalDAV: account ID mismatch"); + } + // Credentials are stored externally; nothing to erase in this provider. + // The cache retains provider data (deterministic IDs make re-add idempotent). +} + +} // namespace nocal::sync