feat: CalDAV sync adapter with calendar discovery and delta sync
Add CalDAV protocol implementation: principal discovery, calendar-home-set lookup, calendar listing, and sync-collection delta sync. CalDAVSync parses iCalendar events from CalDAV resources and populates the sync cache. CalDAVSyncProvider implements SyncProvider for account-level orchestration.
This commit is contained in:
21
include/nocal/sync/caldav_account.hpp
Normal file
21
include/nocal/sync/caldav_account.hpp
Normal file
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
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
|
||||
52
include/nocal/sync/caldav_sync.hpp
Normal file
52
include/nocal/sync/caldav_sync.hpp
Normal file
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include "nocal/sync/caldav_account.hpp"
|
||||
#include "nocal/sync/http.hpp"
|
||||
#include "nocal/storage/sync_cache.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<CalDAVCalendar> 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<HttpHeader>& 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
|
||||
31
include/nocal/sync/caldav_sync_provider.hpp
Normal file
31
include/nocal/sync/caldav_sync_provider.hpp
Normal file
@@ -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 <string>
|
||||
|
||||
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
|
||||
@@ -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',
|
||||
|
||||
42
src/sync/caldav_account.cpp
Normal file
42
src/sync/caldav_account.cpp
Normal file
@@ -0,0 +1,42 @@
|
||||
#include "nocal/sync/caldav_account.hpp"
|
||||
|
||||
#include <openssl/evp.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace nocal::sync {
|
||||
namespace {
|
||||
|
||||
[[nodiscard]] std::string sha256_hex(std::string_view input) {
|
||||
std::array<unsigned char, 32> 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
|
||||
434
src/sync/caldav_sync.cpp
Normal file
434
src/sync/caldav_sync.cpp
Normal file
@@ -0,0 +1,434 @@
|
||||
#include "nocal/sync/caldav_sync.hpp"
|
||||
|
||||
#include "nocal/sync/caldav_xml.hpp"
|
||||
#include "nocal/storage/ics_store.hpp"
|
||||
|
||||
#include <openssl/evp.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace nocal::sync {
|
||||
namespace {
|
||||
|
||||
[[nodiscard]] std::string sha256_hex(std::string_view input) {
|
||||
std::array<unsigned char, 32> 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<std::uint32_t>(
|
||||
static_cast<unsigned char>(input[i]))
|
||||
<< 16)
|
||||
| (static_cast<std::uint32_t>(static_cast<unsigned char>(input[i + 1])) << 8)
|
||||
| static_cast<std::uint32_t>(static_cast<unsigned char>(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<std::uint32_t>(
|
||||
static_cast<unsigned char>(input[i]))
|
||||
<< 16);
|
||||
if (remaining == 2) {
|
||||
const std::uint32_t second = static_cast<std::uint32_t>(
|
||||
static_cast<unsigned char>(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"(<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:principal-URL/>
|
||||
</D:prop>
|
||||
</D:propfind>)";
|
||||
|
||||
std::vector<HttpHeader> headers{
|
||||
{"Depth", "0"},
|
||||
{"Content-Type", "application/xml; charset=utf-8"},
|
||||
{"Authorization", "Basic " + encode_basic_auth()},
|
||||
};
|
||||
|
||||
const std::string principal_response = send_request(
|
||||
static_cast<int>(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"(<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<C:calendar-home-set xmlns:C="urn:ietf:params:xml:ns:caldav"/>
|
||||
</D:prop>
|
||||
</D:propfind>)";
|
||||
|
||||
const std::string home_response = send_request(
|
||||
static_cast<int>(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<CalDAVCalendar> CalDAVSync::list_calendars() {
|
||||
const std::string calendar_body = R"(<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:displayname/>
|
||||
<D:resourcetype/>
|
||||
<D:sync-token/>
|
||||
<C:calendar-description/>
|
||||
</D:prop>
|
||||
</D:propfind>)";
|
||||
|
||||
std::vector<HttpHeader> headers{
|
||||
{"Depth", "1"},
|
||||
{"Content-Type", "application/xml; charset=utf-8"},
|
||||
{"Authorization", "Basic " + encode_basic_auth()},
|
||||
};
|
||||
|
||||
const std::string response = send_request(
|
||||
static_cast<int>(HttpMethod::propfind), calendar_home_set_,
|
||||
calendar_body, headers);
|
||||
|
||||
const auto root = parse_xml(response);
|
||||
const auto responses = find_children(root, "D:response");
|
||||
|
||||
std::vector<CalDAVCalendar> 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<storage::CachedCalendar> 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{}
|
||||
: "<D:sync-token>" + std::string(sync_token) + "</D:sync-token>";
|
||||
|
||||
const std::string sync_body =
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n"
|
||||
"<D:sync-collection xmlns:D=\"DAV:\">\n"
|
||||
" <D:sync-level>1</D:sync-level>\n"
|
||||
+ token_xml + "\n"
|
||||
" <D:prop>\n"
|
||||
" <D:getetag/>\n"
|
||||
" </D:prop>\n"
|
||||
"</D:sync-collection>";
|
||||
|
||||
std::vector<HttpHeader> headers{
|
||||
{"Depth", "1"},
|
||||
{"Content-Type", "application/xml; charset=utf-8"},
|
||||
{"Authorization", "Basic " + encode_basic_auth()},
|
||||
};
|
||||
|
||||
const std::string response = send_request(
|
||||
static_cast<int>(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<std::string> event_hrefs;
|
||||
std::vector<std::string> 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<std::chrono::microseconds>(event.start);
|
||||
instance.start_epoch_microseconds = start_tp.time_since_epoch().count();
|
||||
const auto end_tp = std::chrono::time_point_cast<std::chrono::microseconds>(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<HttpHeader>& extra_headers) {
|
||||
HttpRequest request;
|
||||
request.method = static_cast<HttpMethod>(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
|
||||
106
src/sync/caldav_sync_provider.cpp
Normal file
106
src/sync/caldav_sync_provider.cpp
Normal file
@@ -0,0 +1,106 @@
|
||||
#include "nocal/sync/caldav_sync_provider.hpp"
|
||||
|
||||
#include "nocal/sync/caldav_sync.hpp"
|
||||
#include "nocal/storage/sync_cache.hpp"
|
||||
|
||||
#include <openssl/evp.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
namespace nocal::sync {
|
||||
namespace {
|
||||
|
||||
[[nodiscard]] std::string sha256_hex(std::string_view input) {
|
||||
std::array<unsigned char, 32> 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<std::size_t>(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<std::size_t>(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
|
||||
Reference in New Issue
Block a user