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/domain/month_layout.cpp',
|
||||||
'src/storage/ics_store.cpp',
|
'src/storage/ics_store.cpp',
|
||||||
'src/storage/sync_cache.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/caldav_xml.cpp',
|
||||||
'src/sync/curl_http.cpp',
|
'src/sync/curl_http.cpp',
|
||||||
'src/sync/desktop_oauth.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