From e0467696305c60d6e80bce2f787099a7eb055e19 Mon Sep 17 00:00:00 2001 From: byquanton <32410361+byquanton@users.noreply.github.com> Date: Tue, 5 May 2026 23:48:21 +0200 Subject: [PATCH 1/5] WIP Certificate Trusting --- core/Enum.vala | 3 +- core/Objects/Source.vala | 8 - core/Services/CalDAV/CalDAVClient.vala | 145 ++++----- .../CalDAV/CertificateTrustStore.vala | 274 ++++++++++++++++++ core/Services/CalDAV/Core.vala | 60 +++- core/Services/CalDAV/Providers/Nextcloud.vala | 15 +- core/Services/CalDAV/WebDAVClient.vala | 16 +- core/Services/Database.vala | 148 +++++++++- core/Services/Store.vala | 5 + core/meson.build | 1 + .../Preferences/Pages/Accounts/Accounts.vala | 26 +- .../Pages/Accounts/CalDAVSetup.vala | 51 +++- .../Pages/Accounts/CertificateDetails.vala | 233 +++++++++++++++ .../Pages/Accounts/NextcloudSetup.vala | 28 +- .../Pages/Accounts/SourceView.vala | 211 +++++++++++++- src/Dialogs/Preferences/Pages/BasePage.vala | 25 ++ src/Layouts/SidebarSourceRow.vala | 12 + src/Widgets/IgnoreSSLSwitchRow.vala | 70 ----- src/meson.build | 2 +- test/test-caldav-integration.vala | 7 +- 20 files changed, 1104 insertions(+), 236 deletions(-) create mode 100644 core/Services/CalDAV/CertificateTrustStore.vala create mode 100644 src/Dialogs/Preferences/Pages/Accounts/CertificateDetails.vala delete mode 100644 src/Widgets/IgnoreSSLSwitchRow.vala diff --git a/core/Enum.vala b/core/Enum.vala index 0184273cb..7449987d6 100644 --- a/core/Enum.vala +++ b/core/Enum.vala @@ -753,7 +753,8 @@ public enum Appearance { public enum SyncErrorType { AUTH_EXPIRED, SERVER_ERROR, - NETWORK_ERROR + NETWORK_ERROR, + CERTIFICATE_ERROR } public class SyncStatus : Object { diff --git a/core/Objects/Source.vala b/core/Objects/Source.vala index ee19161a6..1e45ae875 100644 --- a/core/Objects/Source.vala +++ b/core/Objects/Source.vala @@ -374,7 +374,6 @@ public class Objects.SourceCalDAVData : Objects.SourceData { } public CalDAVType caldav_type { get; set; default = CalDAVType.GENERIC; } - public bool ignore_ssl { get; set; default = false; } public SourceCalDAVData.from_json (string json) { Json.Parser parser = new Json.Parser (); @@ -411,10 +410,6 @@ public class Objects.SourceCalDAVData : Objects.SourceData { caldav_type = CalDAVType.parse (object.get_string_member ("caldav_type")); } - if (object.has_member ("ignore_ssl")) { - ignore_ssl = object.get_boolean_member ("ignore_ssl"); - } - if (object.has_member ("credentials")) { var decoded = (string) Base64.decode (object.get_string_member ("credentials")); @@ -454,9 +449,6 @@ public class Objects.SourceCalDAVData : Objects.SourceData { builder.set_member_name ("calendar_home_url"); builder.add_string_value (calendar_home_url); - builder.set_member_name ("ignore_ssl"); - builder.add_boolean_value (ignore_ssl); - builder.end_object (); Json.Generator generator = new Json.Generator (); diff --git a/core/Services/CalDAV/CalDAVClient.vala b/core/Services/CalDAV/CalDAVClient.vala index 041937d1a..055290aad 100644 --- a/core/Services/CalDAV/CalDAVClient.vala +++ b/core/Services/CalDAV/CalDAVClient.vala @@ -22,8 +22,8 @@ public class Services.CalDAV.CalDAVClient : Services.CalDAV.WebDAVClient { - public CalDAVClient (Soup.Session session, string base_url, string username, string password, bool ignore_ssl = false) { - base (session, base_url, username, password, ignore_ssl); + public CalDAVClient (Soup.Session session, string base_url, string username, string password, string source_id) { + base (session, base_url, username, password, source_id); } @@ -422,97 +422,98 @@ public class Services.CalDAV.CalDAVClient : Services.CalDAV.WebDAVClient { project.loading = true; project.sync_started (); - yield fetch_project_details (project, cancellable); - - Services.LogService.get_default ().debug ("CalDAV", "sync_id after fetch_project_details: '%s'".printf (project.sync_id ?? "(null)")); - - if (project.sync_id == null || project.sync_id == "") { - Services.LogService.get_default ().warn ("CalDAV", "No sync-token from server, falling back to etag-based sync for '%s'".printf (project.name)); - project.loading = false; - project.freeze_update = false; - yield etag_sync_project (project, cancellable); - project.sync_finished (); - return; - } - - WebDAVMultiStatus multi_status; try { - multi_status = yield report (project.calendar_url, xml, "1", cancellable); - } catch (Error e) { - if (e is GLib.IOError.CANCELLED) { - throw e; - } - // sync-collection fails with a 412 Precondition Failed on Vikunja (but it sends a sync-token?) + yield fetch_project_details (project, cancellable); - Services.LogService.get_default ().warn ("CalDAV", "sync-collection failed, falling back to ETag sync: %s".printf (e.message)); - project.loading = false; - project.freeze_update = false; - yield etag_sync_project (project, cancellable); - project.sync_finished (); - return; - } - project.freeze_update = true; + Services.LogService.get_default ().debug ("CalDAV", "sync_id after fetch_project_details: '%s'".printf (project.sync_id ?? "(null)")); - foreach (WebDAVResponse response in multi_status.responses ()) { - string? href = response.href; - var url = get_absolute_url (href); + if (project.sync_id == null || project.sync_id == "") { + Services.LogService.get_default ().warn ("CalDAV", "No sync-token from server, falling back to etag-based sync for '%s'".printf (project.name)); + yield etag_sync_project (project, cancellable); + project.sync_finished (); + return; + } - if (response.status == Soup.Status.NOT_FOUND) { - Objects.Item ? item = Services.Store.instance ().get_item_by_ical_url (url); - if (item != null) { - Services.Store.instance ().delete_item (item); + WebDAVMultiStatus multi_status; + try { + multi_status = yield report (project.calendar_url, xml, "1", cancellable); + } catch (Error e) { + if (e is GLib.IOError.CANCELLED) { + throw e; } + // sync-collection fails with a 412 Precondition Failed on Vikunja (but it sends a sync-token?) - continue; + Services.LogService.get_default ().warn ("CalDAV", "sync-collection failed, falling back to ETag sync: %s".printf (e.message)); + yield etag_sync_project (project, cancellable); + project.sync_finished (); + return; } + project.freeze_update = true; - foreach (WebDAVPropStat propstat in response.propstats ()) { - if (propstat.status == Soup.Status.NOT_FOUND) { + foreach (WebDAVResponse response in multi_status.responses ()) { + string? href = response.href; + var url = get_absolute_url (href); + + if (response.status == Soup.Status.NOT_FOUND) { Objects.Item ? item = Services.Store.instance ().get_item_by_ical_url (url); if (item != null) { Services.Store.instance ().delete_item (item); } - } else { - bool has_component_parameter = false; - bool is_vtodo = false; - var getcontenttype = propstat.get_first_prop_with_tagname ("getcontenttype"); - if (getcontenttype != null) { - has_component_parameter = getcontenttype.text_content.down ().contains ("component"); - is_vtodo = getcontenttype.text_content.down ().contains ("vtodo"); - } + continue; + } - if (!has_component_parameter) { - Services.LogService.get_default ().debug ("CalDAV", "No 'component' parameter present in getcontenttype."); - // See https://datatracker.ietf.org/doc/html/rfc5545#section-8.1 -> The component parameter is optional. If it is not present, the iCal data must always be fetched and parsed. - } + foreach (WebDAVPropStat propstat in response.propstats ()) { + if (propstat.status == Soup.Status.NOT_FOUND) { + Objects.Item ? item = Services.Store.instance ().get_item_by_ical_url (url); + if (item != null) { + Services.Store.instance ().delete_item (item); + } + } else { + bool has_component_parameter = false; + bool is_vtodo = false; - if (!has_component_parameter || is_vtodo) { - var getetag = propstat.get_first_prop_with_tagname ("getetag"); - string etag = getetag != null ? getetag.text_content.strip () : ""; + var getcontenttype = propstat.get_first_prop_with_tagname ("getcontenttype"); + if (getcontenttype != null) { + has_component_parameter = getcontenttype.text_content.down ().contains ("component"); + is_vtodo = getcontenttype.text_content.down ().contains ("vtodo"); + } + + if (!has_component_parameter) { + Services.LogService.get_default ().debug ("CalDAV", "No 'component' parameter present in getcontenttype."); + // See https://datatracker.ietf.org/doc/html/rfc5545#section-8.1 -> The component parameter is optional. If it is not present, the iCal data must always be fetched and parsed. + } - string vtodo_content = yield get_vtodo_by_url (url, cancellable); - upsert_vtodo_content (project, url, etag, vtodo_content); + if (!has_component_parameter || is_vtodo) { + var getetag = propstat.get_first_prop_with_tagname ("getetag"); + string etag = getetag != null ? getetag.text_content.strip () : ""; + + string vtodo_content = yield get_vtodo_by_url (url, cancellable); + upsert_vtodo_content (project, url, etag, vtodo_content); + } } } } - } - var sync_token = multi_status.get_first_text_content_by_tag_name ("sync-token"); - if (sync_token != null && sync_token != project.sync_id) { - project.sync_id = sync_token; - project.update_local (); - } else if (sync_token == null) { - // Some CalDAV providers do not support sync-token. Keep token empty - // so subsequent syncs always take the ETag fallback path. - project.sync_id = ""; - project.update_local (); - } + var sync_token = multi_status.get_first_text_content_by_tag_name ("sync-token"); + if (sync_token != null && sync_token != project.sync_id) { + project.sync_id = sync_token; + project.update_local (); + } else if (sync_token == null) { + // Some CalDAV providers do not support sync-token. Keep token empty + // so subsequent syncs always take the ETag fallback path. + project.sync_id = ""; + project.update_local (); + } - project.loading = false; - project.freeze_update = false; - project.count_update (); - Services.Store.instance ().update_project (project); + project.count_update (); + Services.Store.instance ().update_project (project); + + project.sync_finished (); + } finally { + project.loading = false; + project.freeze_update = false; + } } diff --git a/core/Services/CalDAV/CertificateTrustStore.vala b/core/Services/CalDAV/CertificateTrustStore.vala new file mode 100644 index 000000000..c018c82c1 --- /dev/null +++ b/core/Services/CalDAV/CertificateTrustStore.vala @@ -0,0 +1,274 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +public class Services.CalDAV.RejectedCertificateContext : GLib.Object { + public string url { get; construct; } + public GLib.TlsCertificate certificate { get; construct; } + public GLib.TlsCertificateFlags flags { get; construct; } + + public RejectedCertificateContext (string url, GLib.TlsCertificate certificate, GLib.TlsCertificateFlags flags) { + Object (url: url, certificate: certificate, flags: flags); + } +} + +public class Services.CalDAV.CertificateTrustStore : GLib.Object { + private static CertificateTrustStore ? _instance; + private Gee.HashMap > pending_trusted_certificates_by_source; + private Gee.HashMap tls_failures_by_source; + + public static CertificateTrustStore get_default () { + if (_instance == null) { + _instance = new CertificateTrustStore (); + } + return _instance; + } + + private CertificateTrustStore () { + pending_trusted_certificates_by_source = new Gee.HashMap > (); + tls_failures_by_source = new Gee.HashMap (); + } + + private string ? extract_normalized_host (string url) { + try { + var uri = GLib.Uri.parse (url, GLib.UriFlags.NONE); + var host = uri.get_host (); + if (host == null) { + return null; + } + + var normalized_host = host.strip ().down (); + if (normalized_host.contains (" ")) { + return null; + } + + return normalized_host; + } catch (Error e) { + Services.LogService.get_default ().warn ("CertificateTrustStore", "Failed to parse host from URL '%s': %s".printf (url, e.message)); + return null; + } + } + + public RejectedCertificateContext ? get_last_tls_failure_for_source (string source_id) { + return tls_failures_by_source.has_key (source_id) ? tls_failures_by_source[source_id] : null; + } + + public string compute_certificate_sha256_fingerprint (GLib.TlsCertificate certificate) { + return Checksum.compute_for_string (ChecksumType.SHA256, certificate.certificate_pem, -1); + } + + public string build_tls_failure_message_for_source (string source_id) { + var context = get_last_tls_failure_for_source (source_id); + + if (context == null) { + return _("The server certificate could not be verified."); + } + + var host = extract_normalized_host (context.url); + var server_label = host ?? context.url; + + var cert = context.certificate; + GLib.TlsCertificateFlags errors = context.flags; + + var parts = new Gee.ArrayList (); + + if ((errors & GLib.TlsCertificateFlags.UNKNOWN_CA) != 0) { + parts.add (_("The certificate is signed by an untrusted authority")); + } + + if ((errors & GLib.TlsCertificateFlags.BAD_IDENTITY) != 0) { + var subject = cert.get_subject_name () ?? _("unknown subject"); + parts.add (_("Host mismatch (%s)").printf (subject)); + } + + + if ((errors & GLib.TlsCertificateFlags.NOT_ACTIVATED) != 0) { + var not_before = cert.get_not_valid_before (); + + string date = _("unknown"); + if (not_before != null) { + date = "%s, %s".printf ( + not_before.format (Utils.Datetime.get_default_date_format (false, true, true)), + not_before.format (Utils.Datetime.get_default_time_format ()) + ); + } + + parts.add (_("Not yet valid (from %s)").printf (date)); + } + + if ((errors & GLib.TlsCertificateFlags.EXPIRED) != 0) { + var not_after = cert.get_not_valid_after (); + + string date = _("unknown"); + if (not_after != null) { + date = "%s, %s".printf ( + not_after.format (Utils.Datetime.get_default_date_format (false, true, true)), + not_after.format (Utils.Datetime.get_default_time_format ()) + ); + } + + parts.add (_("Expired (since %s)").printf (date)); + } + + if ((errors & GLib.TlsCertificateFlags.REVOKED) != 0) { + parts.add (_("Revoked certificate")); + } + + if ((errors & GLib.TlsCertificateFlags.INSECURE) != 0) { + parts.add (_("Insecure cryptography")); + } + + if ((errors & GLib.TlsCertificateFlags.GENERIC_ERROR) != 0) { + parts.add (_("Generic TLS error")); + } + + string details; + + if (parts.size == 0) { + details = _("Unknown validation error"); + } else { + details = ""; + foreach (var part in parts) { + details = details + "\n - " + part; + } + } + + return _("Certificate validation for host '%s' failed: %s").printf ( + server_label, + details + ); + } + + private bool is_certificate_already_trusted_for_source (string source_id, string url, GLib.TlsCertificate certificate) { + var host = extract_normalized_host (url); + if (host == null) { + return false; + } + + var certificate_fingerprint = compute_certificate_sha256_fingerprint (certificate); + + var db_fingerprint = Services.Database.get_default ().get_trusted_certificate_fingerprint (source_id, host); + if (db_fingerprint != null && db_fingerprint == certificate_fingerprint) { + return true; + } + + if (pending_trusted_certificates_by_source.has_key (source_id)) { + var pending_trusted_certificates = pending_trusted_certificates_by_source[source_id]; + if (pending_trusted_certificates.has_key (host) && pending_trusted_certificates[host] == certificate_fingerprint) { + return true; + } + } + + return false; + } + + public void attach_certificate_handler (Soup.Message message, string source_id, string url) { + message.accept_certificate.connect ((certificate, errors) => { + if (errors != GLib.TlsCertificateFlags.UNKNOWN_CA) { + Services.LogService.get_default ().warn ( + "CertificateTrustStore", + build_tls_failure_message_for_source (source_id) + ); + tls_failures_by_source[source_id] = new RejectedCertificateContext (url, certificate, errors); + return false; + } + + if (is_certificate_already_trusted_for_source (source_id, url, certificate)) { + tls_failures_by_source.unset (source_id); + return true; + } + + tls_failures_by_source[source_id] = new RejectedCertificateContext (url, certificate, errors); + Services.LogService.get_default ().info ("CertificateTrustStore", "Rejected certificate remembered for source '%s' and URL '%s'".printf (source_id, url)); + return false; + }); + } + + public bool trust_unknown_ca_certificate_for_source (string source_id) { + var context = get_last_tls_failure_for_source (source_id); + if (context == null || context.flags != GLib.TlsCertificateFlags.UNKNOWN_CA) { + return false; + } + + var host = extract_normalized_host (context.url); + if (host == null) { + return false; + } + + if (!pending_trusted_certificates_by_source.has_key (source_id)) { + pending_trusted_certificates_by_source[source_id] = new Gee.HashMap (); + } + + var pending_trusted_certificates = pending_trusted_certificates_by_source[source_id]; + pending_trusted_certificates[host] = compute_certificate_sha256_fingerprint (context.certificate); + + tls_failures_by_source.unset (source_id); + + Services.LogService.get_default ().info ("CertificateTrustStore", "Rejected certificate accepted for source '%s' and host '%s'".printf (source_id, host)); + return true; + } + + public bool remove_trusted_certificate_for_source (string source_id, string host) { + var host_key = host.down (); + var deleted = Services.Database.get_default ().delete_trusted_certificate (source_id, host_key); + + if (pending_trusted_certificates_by_source.has_key (source_id)) { + var pending_trusted_certificates = pending_trusted_certificates_by_source[source_id]; + pending_trusted_certificates.unset (host_key); + + if (pending_trusted_certificates.size == 0) { + pending_trusted_certificates_by_source.unset (source_id); + } + } + + if (deleted) { + Services.LogService.get_default ().info ("CertificateTrustStore", "Trusted certificate deleted for source '%s' and host '%s'".printf (source_id, host_key)); + } + + return deleted; + } + + public bool persist_pending_trusted_certificates_for_source (string source_id) { + if (!pending_trusted_certificates_by_source.has_key (source_id)) { + return true; + } + + var pending_trusted_certificates = pending_trusted_certificates_by_source[source_id]; + if (!Services.Database.get_default ().upsert_trusted_certificates_transaction (source_id, pending_trusted_certificates)) { + Services.LogService.get_default ().warn ( + "CertificateTrustStore", + "Failed to persist pending trusted certificates for source '%s'. In-memory state preserved for retry.".printf (source_id) + ); + return false; + } + + pending_trusted_certificates_by_source.unset (source_id); + + Services.LogService.get_default ().info ("CertificateTrustStore", "Pending trusted certificates persisted for source '%s'".printf (source_id)); + return true; + } + + public Gee.ArrayList > get_trusted_certificates_for_source (string source_id) { + return Services.Database.get_default ().get_trusted_certificates_for_source (source_id); + } + + public void clear_source_state (string source_id) { + pending_trusted_certificates_by_source.unset (source_id); + tls_failures_by_source.unset (source_id); + } +} diff --git a/core/Services/CalDAV/Core.vala b/core/Services/CalDAV/Core.vala index e6dc19b91..dd650abe1 100644 --- a/core/Services/CalDAV/Core.vala +++ b/core/Services/CalDAV/Core.vala @@ -47,7 +47,7 @@ public class Services.CalDAV.Core : GLib.Object { source.caldav_data.server_url, source.caldav_data.username, source.caldav_data.password, - source.caldav_data.ignore_ssl + source.id ); clients[source.id] = client; } @@ -63,10 +63,16 @@ public class Services.CalDAV.Core : GLib.Object { public void remove_client (string source_id) { Services.LogService.get_default ().info ("CalDAV.Core", "Removing client"); + if (clients.has_key (source_id)) { + clients[source_id].cleanup (); + } clients.unset (source_id); } public void clear () { + foreach (var client in clients.values) { + client.cleanup (); + } clients.clear (); } @@ -81,7 +87,7 @@ public class Services.CalDAV.Core : GLib.Object { return abs_url; } - public async string resolve_well_known_caldav (Soup.Session session, string base_url, bool ignore_ssl = false) throws GLib.Error { + public async string resolve_well_known_caldav (Soup.Session session, string base_url, string source_id) throws GLib.Error { Services.LogService.get_default ().info ("CalDAV.Core", "Resolving .well-known/caldav"); var well_known_url = make_absolute_url (base_url, "/.well-known/caldav"); var msg = new Soup.Message ("GET", well_known_url); @@ -89,11 +95,7 @@ public class Services.CalDAV.Core : GLib.Object { msg.set_flags (Soup.MessageFlags.NO_REDIRECT); - if (ignore_ssl) { - msg.accept_certificate.connect (() => { - return true; - }); - } + Services.CalDAV.CertificateTrustStore.get_default ().attach_certificate_handler (msg, source_id, well_known_url); try { yield session.send_and_read_async (msg, Priority.DEFAULT, null); @@ -136,9 +138,9 @@ public class Services.CalDAV.Core : GLib.Object { } - public async string? resolve_calendar_home (CalDAVType caldav_type, string dav_url, string username, string password, GLib.Cancellable cancellable, bool ignore_ssl = false) throws GLib.Error { + public async string? resolve_calendar_home (CalDAVType caldav_type, string dav_url, string username, string password, GLib.Cancellable cancellable, string source_id) throws GLib.Error { Services.LogService.get_default ().info ("CalDAV.Core", "Resolving calendar home"); - var caldav_client = new Services.CalDAV.CalDAVClient (new Soup.Session (), dav_url, username, password, ignore_ssl); + var caldav_client = new Services.CalDAV.CalDAVClient (new Soup.Session (), dav_url, username, password, source_id); try { string? principal_url = yield caldav_client.get_principal_url (cancellable); @@ -159,7 +161,7 @@ public class Services.CalDAV.Core : GLib.Object { } } - public async HttpResponse login (CalDAVType caldav_type, string dav_url, string username, string password, string calendar_home, GLib.Cancellable cancellable, bool ignore_ssl = false) { + public async HttpResponse login (CalDAVType caldav_type, string dav_url, string username, string password, string calendar_home, GLib.Cancellable cancellable, string source_id) { Services.LogService.get_default ().info ("CalDAV.Core", "Starting login"); HttpResponse response = new HttpResponse (); @@ -170,7 +172,7 @@ public class Services.CalDAV.Core : GLib.Object { return response; } - var caldav_client = new Services.CalDAV.CalDAVClient (new Soup.Session (), dav_url, username, password, ignore_ssl); + var caldav_client = new Services.CalDAV.CalDAVClient (new Soup.Session (), dav_url, username, password, source_id); try { string? principal_url = yield caldav_client.get_principal_url (cancellable); @@ -182,7 +184,7 @@ public class Services.CalDAV.Core : GLib.Object { } var source = new Objects.Source (); - source.id = Util.get_default ().generate_id (); + source.id = source_id; source.source_type = SourceType.CALDAV; source.last_sync = new GLib.DateTime.now_local ().to_string (); source.sync_server = true; @@ -193,7 +195,6 @@ public class Services.CalDAV.Core : GLib.Object { caldav_data.username = username; caldav_data.password = password; caldav_data.caldav_type = caldav_type; - caldav_data.ignore_ssl = ignore_ssl; source.data = caldav_data; @@ -261,8 +262,16 @@ public class Services.CalDAV.Core : GLib.Object { // to avoid concurrent sync triggering duplicate project insertion Services.Store.instance ().insert_source (source); + + if (!Services.CalDAV.CertificateTrustStore.get_default ().persist_pending_trusted_certificates_for_source (source.id)) { + response.error_code = 409; + response.error = _("Failed to save trusted certificates"); + return response; + } + sync_progress (projects.size, projects.size, _("Sync completed")); Services.LogService.get_default ().info ("CalDAV.Core", "Account added successfully, %d projects synced".printf (projects.size)); + Services.CalDAV.CertificateTrustStore.get_default ().clear_source_state (source.id); response.status = true; } catch (Error e) { response.error_code = e.code; @@ -294,9 +303,9 @@ public class Services.CalDAV.Core : GLib.Object { yield caldav_client.sync_tasklist (project, cancellable); } - source.sync_finished (); source.sync_status = null; source.last_sync = new GLib.DateTime.now_local ().to_string (); + source.sync_finished (); Services.LogService.get_default ().info ("CalDAV.Core", "Sync completed successfully"); } catch (Error e) { Services.LogService.get_default ().error ("CalDAV.Core", "Failed to sync source '%s': %s".printf (source.display_name, e.message)); @@ -317,6 +326,29 @@ public class Services.CalDAV.Core : GLib.Object { _("The server is rate limiting requests. Please wait a few minutes and try again.") ); source.sync_failed (source.sync_status); + } else if (e is GLib.TlsError.BAD_CERTIFICATE) { + var certificate_store = Services.CalDAV.CertificateTrustStore.get_default (); + var tls_failure_context = certificate_store.get_last_tls_failure_for_source (source.id); + if (tls_failure_context != null && tls_failure_context.flags == GLib.TlsCertificateFlags.UNKNOWN_CA) { + source.sync_status = new SyncStatus ( + SyncErrorType.CERTIFICATE_ERROR, + _("Untrusted Certificate Authority"), + _("Your system does not trust the server's self-signed certificate.") + ); + Services.LogService.get_default ().warn ("CalDAV.Core", "Sync failed with untrusted certificate for source '%s'".printf (source.id)); + } else { + var tls_error_message = certificate_store.build_tls_failure_message_for_source (source.id); + source.sync_status = new SyncStatus ( + SyncErrorType.SERVER_ERROR, + _("Server Error"), + tls_error_message + ); + Services.LogService.get_default ().error ( + "CalDAV.Core", + "The sync failed with a BAD_CERTIFICATE error for source '%s': %s".printf (source.id, tls_error_message) + ); + } + source.sync_failed (source.sync_status); } else { source.sync_status = null; source.sync_failed (); diff --git a/core/Services/CalDAV/Providers/Nextcloud.vala b/core/Services/CalDAV/Providers/Nextcloud.vala index a29c2f4a1..317a9e1b7 100644 --- a/core/Services/CalDAV/Providers/Nextcloud.vala +++ b/core/Services/CalDAV/Providers/Nextcloud.vala @@ -54,7 +54,7 @@ public class Services.CalDAV.Providers.Nextcloud : Object { return server_url; } - public async HttpResponse start_login_flow (string server_url, GLib.Cancellable cancellable, bool ignore_ssl = false) { + public async HttpResponse start_login_flow (string server_url, GLib.Cancellable cancellable, string source_id) { Services.LogService.get_default ().info ("Nextcloud", "Starting login flow"); HttpResponse response = new HttpResponse (); @@ -63,11 +63,7 @@ public class Services.CalDAV.Providers.Nextcloud : Object { var message = new Soup.Message ("POST", login_url); message.request_headers.append ("User-Agent", Constants.SOUP_USER_AGENT); // The User Agent is used by Nextcloud for the App Name - if (ignore_ssl) { - message.accept_certificate.connect (() => { - return true; - }); - } + Services.CalDAV.CertificateTrustStore.get_default ().attach_certificate_handler (message, source_id, login_url); try { GLib.Bytes stream = yield session.send_and_read_async (message, GLib.Priority.HIGH, cancellable); @@ -94,6 +90,7 @@ public class Services.CalDAV.Providers.Nextcloud : Object { poll_msg.request_headers.append ("User-Agent", Constants.SOUP_USER_AGENT); poll_msg.set_request_body_from_bytes ("application/json", new Bytes ("""{ "token": "%s" }""".printf (poll_token).data)); + Services.CalDAV.CertificateTrustStore.get_default ().attach_certificate_handler (poll_msg, source_id, poll_endpoint); try { GLib.Bytes poll_response = yield session.send_and_read_async (poll_msg, GLib.Priority.HIGH, cancellable); @@ -115,13 +112,13 @@ public class Services.CalDAV.Providers.Nextcloud : Object { var login_name = poll_object.get_string_member ("loginName"); var app_password = poll_object.get_string_member ("appPassword"); - var dav_endpoint = yield Core.get_default ().resolve_well_known_caldav (session, server); + var dav_endpoint = yield Core.get_default ().resolve_well_known_caldav (session, server, source_id); Services.LogService.get_default ().info ("Nextcloud", "Resolved well-known CalDAV endpoint"); - var calendar_home = yield Core.get_default ().resolve_calendar_home (CalDAVType.NEXTCLOUD, dav_endpoint, login_name, app_password, cancellable, ignore_ssl); + var calendar_home = yield Core.get_default ().resolve_calendar_home (CalDAVType.NEXTCLOUD, dav_endpoint, login_name, app_password, cancellable, source_id); Services.LogService.get_default ().info ("Nextcloud", "Resolved calendar home"); - var login_response = yield Core.get_default ().login (CalDAVType.NEXTCLOUD, dav_endpoint, login_name, app_password, calendar_home, cancellable, ignore_ssl); + var login_response = yield Core.get_default ().login (CalDAVType.NEXTCLOUD, dav_endpoint, login_name, app_password, calendar_home, cancellable, source_id); return login_response; } diff --git a/core/Services/CalDAV/WebDAVClient.vala b/core/Services/CalDAV/WebDAVClient.vala index 01b2782e2..4bcab1710 100644 --- a/core/Services/CalDAV/WebDAVClient.vala +++ b/core/Services/CalDAV/WebDAVClient.vala @@ -26,16 +26,16 @@ public class Services.CalDAV.WebDAVClient : GLib.Object { protected string username; protected string password; protected string base_url; - protected bool ignore_ssl; + protected string source_id; public string? last_response_etag { get; private set; default = null; } - public WebDAVClient (Soup.Session session, string base_url, string username, string password, bool ignore_ssl = false) { + public WebDAVClient (Soup.Session session, string base_url, string username, string password, string source_id) { this.session = session; this.base_url = base_url; this.username = username; this.password = password; - this.ignore_ssl = ignore_ssl; + this.source_id = source_id; } public void cleanup () { @@ -97,11 +97,7 @@ public class Services.CalDAV.WebDAVClient : GLib.Object { } }); - if (ignore_ssl) { - msg.accept_certificate.connect (() => { - return true; - }); - } + Services.CalDAV.CertificateTrustStore.get_default ().attach_certificate_handler (msg, source_id, abs_url); if (depth != null) { msg.request_headers.replace ("Depth", depth); @@ -124,6 +120,10 @@ public class Services.CalDAV.WebDAVClient : GLib.Object { Services.LogService.get_default ().info ("WebDAV", "Request cancelled"); throw e; } + if (e is GLib.TlsError.BAD_CERTIFICATE) { + Services.LogService.get_default ().info ("WebDAV", "The request for the source %s failed due to an invalid certificate.".printf (source_id)); + throw e; + } throw new GLib.IOError.FAILED ("Request failed: %s".printf (e.message)); } diff --git a/core/Services/Database.vala b/core/Services/Database.vala index 231dc178d..e3c88c7e5 100644 --- a/core/Services/Database.vala +++ b/core/Services/Database.vala @@ -175,6 +175,11 @@ public class Services.Database : GLib.Object { table_columns["Sources"].add ("sync_server"); table_columns["Sources"].add ("last_sync"); table_columns["Sources"].add ("data"); + + table_columns["TrustedCertificates"] = new Gee.ArrayList (); + table_columns["TrustedCertificates"].add ("source_id"); + table_columns["TrustedCertificates"].add ("host"); + table_columns["TrustedCertificates"].add ("fingerprint_sha256"); } public void init_database () { @@ -396,6 +401,20 @@ public class Services.Database : GLib.Object { warning (errormsg); } + sql = """ + CREATE TABLE IF NOT EXISTS TrustedCertificates ( + source_id TEXT NOT NULL, + host TEXT NOT NULL, + fingerprint_sha256 TEXT NOT NULL, + PRIMARY KEY (source_id, host), + FOREIGN KEY (source_id) REFERENCES Sources (id) ON DELETE CASCADE + ); + """; + + if (db.exec (sql, null, out errormsg) != Sqlite.OK) { + warning (errormsg); + } + sql = """PRAGMA foreign_keys = ON;"""; if (db.exec (sql, null, out errormsg) != Sqlite.OK) { @@ -726,7 +745,7 @@ public class Services.Database : GLib.Object { // Verify Tables Integrity string[] tables = { "Attachments", "CurTempIds", "Items", "Labels", - "OEvents", "Projects", "Queue", "Reminders", "Sections", "Sources" }; + "OEvents", "Projects", "Queue", "Reminders", "Sections", "Sources", "TrustedCertificates" }; foreach (var table_name in tables) { if (!table_exists (table_name)) { @@ -905,6 +924,133 @@ public class Services.Database : GLib.Object { return true; } + public bool upsert_trusted_certificates_transaction (string source_id, Gee.HashMap trusted_certificates) { + Sqlite.Statement stmt; + + if (source_id == null || source_id == "") { + return false; + } + + if (trusted_certificates == null || trusted_certificates.size == 0) { + return true; + } + + sql = "BEGIN TRANSACTION;"; + if (db.exec (sql, null, out errormsg) != Sqlite.OK) { + warning (errormsg); + return false; + } + + bool success = true; + + sql = """ + INSERT INTO TrustedCertificates (source_id, host, fingerprint_sha256) + VALUES ($source_id, $host, $fingerprint_sha256) + ON CONFLICT(source_id, host) + DO UPDATE SET fingerprint_sha256 = excluded.fingerprint_sha256; + """; + + db.prepare_v2 (sql, sql.length, out stmt); + + foreach (var entry in trusted_certificates.entries) { + set_parameter_str (stmt, "$source_id", source_id); + set_parameter_str (stmt, "$host", entry.key.down ()); + set_parameter_str (stmt, "$fingerprint_sha256", entry.value); + + int result = stmt.step (); + if (result != Sqlite.DONE) { + warning ("Error: %d: %s", db.errcode (), db.errmsg ()); + success = false; + break; + } + + stmt.reset (); + } + + if (success) { + sql = "COMMIT;"; + if (db.exec (sql, null, out errormsg) != Sqlite.OK) { + warning (errormsg); + success = false; + } + } + + if (!success) { + sql = "ROLLBACK;"; + if (db.exec (sql, null, out errormsg) != Sqlite.OK) { + warning (errormsg); + } + } + + return success; + } + + public bool delete_trusted_certificate (string source_id, string host) { + Sqlite.Statement stmt; + + sql = """ + DELETE FROM TrustedCertificates + WHERE source_id = $source_id AND host = $host; + """; + + db.prepare_v2 (sql, sql.length, out stmt); + set_parameter_str (stmt, "$source_id", source_id); + set_parameter_str (stmt, "$host", host.down ()); + + int result = stmt.step (); + if (result != Sqlite.DONE) { + warning ("Error: %d: %s", db.errcode (), db.errmsg ()); + return false; + } + + return true; + } + + public string? get_trusted_certificate_fingerprint (string source_id, string host) { + Sqlite.Statement stmt; + + sql = """ + SELECT fingerprint_sha256 + FROM TrustedCertificates + WHERE source_id = $source_id AND host = $host + LIMIT 1; + """; + + db.prepare_v2 (sql, sql.length, out stmt); + set_parameter_str (stmt, "$source_id", source_id); + set_parameter_str (stmt, "$host", host.down ()); + + if (stmt.step () == Sqlite.ROW) { + return stmt.column_text (0); + } + + return null; + } + + public Gee.ArrayList> get_trusted_certificates_for_source (string source_id) { + var certificates = new Gee.ArrayList> (); + Sqlite.Statement stmt; + + sql = """ + SELECT host, fingerprint_sha256 + FROM TrustedCertificates + WHERE source_id = $source_id + ORDER BY host; + """; + + db.prepare_v2 (sql, sql.length, out stmt); + set_parameter_str (stmt, "$source_id", source_id); + + while (stmt.step () == Sqlite.ROW) { + var certificate = new Gee.HashMap (); + certificate["host"] = stmt.column_text (0); + certificate["fingerprint_sha256"] = stmt.column_text (1); + certificates.add (certificate); + } + + return certificates; + } + /* Projects */ diff --git a/core/Services/Store.vala b/core/Services/Store.vala index 3e58f7f69..03fb7dac1 100644 --- a/core/Services/Store.vala +++ b/core/Services/Store.vala @@ -191,6 +191,11 @@ public class Services.Store : GLib.Object { } public async void delete_source (Objects.Source source) { + if (source.source_type == SourceType.CALDAV) { + Services.CalDAV.Core.get_default ().remove_client (source.id); + Services.CalDAV.CertificateTrustStore.get_default ().clear_source_state (source.id); + } + var projects = get_projects_by_source (source.id); const int BATCH_SIZE = 5; diff --git a/core/meson.build b/core/meson.build index 81b7a6d18..9959be836 100644 --- a/core/meson.build +++ b/core/meson.build @@ -21,6 +21,7 @@ core_files = files( 'Services/ColorSchemeSettings.vala', 'Services/CalDAV/Core.vala', + 'Services/CalDAV/CertificateTrustStore.vala', 'Services/CalDAV/CalDAVClient.vala', 'Services/CalDAV/WebDAVClient.vala', 'Services/CalDAV/Providers/Nextcloud.vala', diff --git a/src/Dialogs/Preferences/Pages/Accounts/Accounts.vala b/src/Dialogs/Preferences/Pages/Accounts/Accounts.vala index 9c308bbf9..c44e7817d 100644 --- a/src/Dialogs/Preferences/Pages/Accounts/Accounts.vala +++ b/src/Dialogs/Preferences/Pages/Accounts/Accounts.vala @@ -293,33 +293,23 @@ public class Dialogs.Preferences.Pages.Accounts : Dialogs.Preferences.Pages.Base valign = CENTER }; - Gtk.Image ? warning_image = null; - if (source.source_type == SourceType.CALDAV && source.caldav_data.ignore_ssl) { - warning_image = new Gtk.Image.from_icon_name ("dialog-warning-symbolic") { - tooltip_text = _("SSL verification is disabled") - }; - } var end_box = new Gtk.Box (HORIZONTAL, 12) { hexpand = true, halign = END }; - var auth_warning_image = new Gtk.Image.from_icon_name ("dialog-warning-symbolic") { + var warning_image = new Gtk.Image.from_icon_name ("dialog-warning-symbolic") { tooltip_text = _("Authentication expired"), visible = source.sync_status != null }; - auth_warning_image.add_css_class ("error"); + warning_image.add_css_class ("error"); if (source.sync_status != null) { - auth_warning_image.tooltip_text = source.sync_status.tooltip; + warning_image.tooltip_text = source.sync_status.tooltip; } - end_box.append (auth_warning_image); - - if (warning_image != null) { - end_box.append (warning_image); - } + end_box.append (warning_image); // Check if Todoist account needs migration if (source.source_type == SourceType.TODOIST && source.needs_migration ()) { @@ -385,13 +375,13 @@ public class Dialogs.Preferences.Pages.Accounts : Dialogs.Preferences.Pages.Base signal_map[source.sync_failed.connect ((status) => { if (status != null) { - auth_warning_image.tooltip_text = status.tooltip; - auth_warning_image.visible = true; + warning_image.tooltip_text = status.tooltip; + warning_image.visible = true; } })] = source; signal_map[source.sync_finished.connect (() => { - auth_warning_image.visible = false; + warning_image.visible = false; })] = source; signal_map[source.updated.connect (() => { @@ -509,11 +499,11 @@ public class Dialogs.Preferences.Pages.Accounts : Dialogs.Preferences.Pages.Base public void hide_destroy () { main_revealer.reveal_child = false; - clean_up (); Timeout.add (main_revealer.transition_duration, () => { ((Gtk.ListBox) parent).remove (this); return GLib.Source.REMOVE; }); + clean_up (); } public void clean_up () { diff --git a/src/Dialogs/Preferences/Pages/Accounts/CalDAVSetup.vala b/src/Dialogs/Preferences/Pages/Accounts/CalDAVSetup.vala index f334544b3..f186eb36e 100644 --- a/src/Dialogs/Preferences/Pages/Accounts/CalDAVSetup.vala +++ b/src/Dialogs/Preferences/Pages/Accounts/CalDAVSetup.vala @@ -32,8 +32,8 @@ public class Dialogs.Preferences.Pages.CalDAVSetup : Dialogs.Preferences.Pages.B // Advanced Options private Adw.EntryRow calendar_home_entry; - private Widgets.IgnoreSSLSwitchRow ignore_ssl_row; private Widgets.BypassResolveSwitchRow bypass_resolve_row; + private string source_id; public CalDAVSetup (Adw.PreferencesDialog preferences_dialog, Accounts accounts_page) { Object ( @@ -48,6 +48,8 @@ public class Dialogs.Preferences.Pages.CalDAVSetup : Dialogs.Preferences.Pages.B } construct { + source_id = Util.get_default ().generate_id (); + var icon = new Gtk.Image.from_icon_name ("network-server-symbolic") { pixel_size = 48, css_classes = { "dimmed" } @@ -93,7 +95,6 @@ public class Dialogs.Preferences.Pages.CalDAVSetup : Dialogs.Preferences.Pages.B calendar_home_entry = new Adw.EntryRow (); calendar_home_entry.title = _("Calendar Home URL"); - ignore_ssl_row = new Widgets.IgnoreSSLSwitchRow (); bypass_resolve_row = new Widgets.BypassResolveSwitchRow (); var advanced_group = new Adw.PreferencesGroup () { @@ -103,7 +104,6 @@ public class Dialogs.Preferences.Pages.CalDAVSetup : Dialogs.Preferences.Pages.B }; advanced_group.title = _("Advanced"); advanced_group.add (calendar_home_entry); - advanced_group.add (ignore_ssl_row); advanced_group.add (bypass_resolve_row); var advanced_revealer = new Gtk.Revealer () { @@ -210,6 +210,7 @@ public class Dialogs.Preferences.Pages.CalDAVSetup : Dialogs.Preferences.Pages.B })] = Services.CalDAV.Core.get_default (); destroy.connect (() => { + Services.CalDAV.CertificateTrustStore.get_default ().clear_source_state (source_id); clean_up (); // Ensure cleanup on dialog destruction Services.CalDAV.Core.get_default ().clear (); @@ -278,16 +279,26 @@ public class Dialogs.Preferences.Pages.CalDAVSetup : Dialogs.Preferences.Pages.B } else { Services.LogService.get_default ().info ("CalDAVSetup", "Resolving well-known CalDAV endpoint"); try { - dav_endpoint = yield Services.CalDAV.Core.get_default ().resolve_well_known_caldav (new Soup.Session (), server_entry.text, ignore_ssl_row.active); + dav_endpoint = yield Services.CalDAV.Core.get_default ().resolve_well_known_caldav (new Soup.Session (), server_entry.text, source_id); } catch (Error e) { if (e is GLib.IOError.CANCELLED) { login_button.is_loading = false; cancel_button.visible = false; return; } + login_button.is_loading = false; cancel_button.visible = false; - accounts_page.show_message_error (0, "Failed to resolve server: %s".printf (e.message)); + + var certificate_store = Services.CalDAV.CertificateTrustStore.get_default (); + var tls_failure_context = certificate_store.get_last_tls_failure_for_source (source_id); + if (tls_failure_context != null) { + open_certificate_details_page (source_id, server_entry.text, accounts_page, () => { + on_login_button_clicked (); + }); + } else { + accounts_page.show_message_error (0, _ ("Failed to resolve CalDAV endpoint: %s").printf (e.message)); + } return; } } @@ -305,7 +316,7 @@ public class Dialogs.Preferences.Pages.CalDAVSetup : Dialogs.Preferences.Pages.B } else { Services.LogService.get_default ().info ("CalDAVSetup", "Resolving calendar home"); try { - calendar_home = yield Services.CalDAV.Core.get_default ().resolve_calendar_home (CalDAVType.GENERIC, dav_endpoint, username_entry.text, password_entry.text, cancellable, ignore_ssl_row.active); + calendar_home = yield Services.CalDAV.Core.get_default ().resolve_calendar_home (CalDAVType.GENERIC, dav_endpoint, username_entry.text, password_entry.text, cancellable, source_id); } catch (Error e) { if (e is GLib.IOError.CANCELLED) { login_button.is_loading = false; @@ -314,7 +325,16 @@ public class Dialogs.Preferences.Pages.CalDAVSetup : Dialogs.Preferences.Pages.B } login_button.is_loading = false; cancel_button.visible = false; - accounts_page.show_message_error (0, "Failed to resolve calendar home: %s".printf (e.message)); + + var certificate_store = Services.CalDAV.CertificateTrustStore.get_default (); + var tls_failure_context = certificate_store.get_last_tls_failure_for_source (source_id); + if (tls_failure_context != null) { + open_certificate_details_page (source_id, dav_endpoint, accounts_page, () => { + on_login_button_clicked (); + }); + } else { + accounts_page.show_message_error (0, _ ("Failed to resolve calendar home: %s").printf (e.message)); + } return; } } @@ -336,7 +356,7 @@ public class Dialogs.Preferences.Pages.CalDAVSetup : Dialogs.Preferences.Pages.B } Services.LogService.get_default ().info ("CalDAVSetup", "Attempting login"); - HttpResponse response = yield Services.CalDAV.Core.get_default ().login (CalDAVType.GENERIC, dav_endpoint, username_entry.text, password_entry.text, calendar_home, cancellable, ignore_ssl_row.active); + HttpResponse response = yield Services.CalDAV.Core.get_default ().login (CalDAVType.GENERIC, dav_endpoint, username_entry.text, password_entry.text, calendar_home, cancellable, source_id); if (response.status) { Services.LogService.get_default ().info ("CalDAVSetup", "Login successful, syncing account"); @@ -359,10 +379,25 @@ public class Dialogs.Preferences.Pages.CalDAVSetup : Dialogs.Preferences.Pages.B login_button.is_loading = false; cancel_button.visible = false; + var certificate_store = Services.CalDAV.CertificateTrustStore.get_default (); + var tls_failure_context = certificate_store.get_last_tls_failure_for_source (source_id); + if (tls_failure_context != null) { + open_certificate_details_page (source_id, dav_endpoint, accounts_page, () => { + on_login_button_clicked (); + }); + return; + } + if (response.error_code == 409) { var toast = new Adw.Toast (response.error.strip ()); toast.timeout = 3; preferences_dialog.add_toast (toast); + } else if (response.error_code == 0 && certificate_store.get_last_tls_failure_for_source (source_id) != null) { + accounts_page.show_message_error ( + 500, + certificate_store.build_tls_failure_message_for_source (source_id), + false + ); } else { accounts_page.show_message_error (response.error_code, response.error.strip ()); } diff --git a/src/Dialogs/Preferences/Pages/Accounts/CertificateDetails.vala b/src/Dialogs/Preferences/Pages/Accounts/CertificateDetails.vala new file mode 100644 index 000000000..3739fc1c5 --- /dev/null +++ b/src/Dialogs/Preferences/Pages/Accounts/CertificateDetails.vala @@ -0,0 +1,233 @@ +/* + * Copyright © 2026 Alain M. (https://github.com/alainm23/planify) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301 USA + */ + +public class Dialogs.Preferences.Pages.CertificateDetails : Dialogs.Preferences.Pages.BasePage { + public string server_url { get; construct; } + public string source_id { get; construct; } + public GLib.TlsCertificate certificate { get; construct; } + + public signal void certificate_trusted (); + + public static Dialogs.Preferences.Pages.CertificateDetails? build_for_source ( + Adw.PreferencesDialog preferences_dialog, + string source_id, + string fallback_server_url, + Dialogs.Preferences.Pages.Accounts? accounts_page = null + ) { + var certificate_store = Services.CalDAV.CertificateTrustStore.get_default (); + var? certificate_context = certificate_store.get_last_tls_failure_for_source (source_id); + if (certificate_context == null || certificate_context.flags != GLib.TlsCertificateFlags.UNKNOWN_CA) { + if (accounts_page != null) { + accounts_page.show_message_error ( + 500, + certificate_store.build_tls_failure_message_for_source (source_id), + false + ); + } + + return null; + } + + return new Dialogs.Preferences.Pages.CertificateDetails ( + preferences_dialog, + certificate_context.url ?? fallback_server_url, + source_id, + certificate_context.certificate + ); + } + + public CertificateDetails (Adw.PreferencesDialog preferences_dialog, string server_url, string source_id, GLib.TlsCertificate certificate) { + Object ( + preferences_dialog: preferences_dialog, + server_url: server_url, + source_id: source_id, + certificate: certificate, + title: _("Review certificate") + ); + } + + construct { + var icon = new Gtk.Image.from_icon_name ("dialog-warning-symbolic") { + pixel_size = 48, + css_classes = { "warning" }, + halign = Gtk.Align.CENTER + }; + + var title_label = new Gtk.Label (_("Untrusted certificate")) { + css_classes = { "title-3", "font-bold" }, + halign = Gtk.Align.CENTER, + margin_top = 12 + }; + + var body_label = new Gtk.Label (_("The certificate for %s is self-signed and thus not trusted").printf (server_url)) { + wrap = true, + justify = Gtk.Justification.CENTER, + max_width_chars = 46, + halign = Gtk.Align.CENTER, + css_classes = { "dimmed" } + }; + + var subject_row = new Adw.ActionRow () { + title = _("Subject"), + subtitle = certificate.get_subject_name () ?? _("Unknown"), + subtitle_selectable = true + }; + + var issuer_row = new Adw.ActionRow () { + title = _("Issuer"), + subtitle = certificate.get_issuer_name () ?? _("Unknown"), + subtitle_selectable = true + }; + + var not_before_row = new Adw.ActionRow () { + title = _("Not valid before"), + subtitle = format_certificate_datetime (certificate.get_not_valid_before ()), + subtitle_selectable = true + }; + + var not_after_row = new Adw.ActionRow () { + title = _("Not valid after"), + subtitle = format_certificate_datetime (certificate.get_not_valid_after ()), + subtitle_selectable = true + }; + + var fingerprint = Services.CalDAV.CertificateTrustStore.get_default ().compute_certificate_sha256_fingerprint (certificate); + var fingerprint_row = new Adw.ActionRow () { + title = _("SHA-256 fingerprint"), + subtitle = fingerprint, + subtitle_selectable = true + }; + + var copy_fingerprint_button = new Gtk.Button.from_icon_name ("edit-copy-symbolic") { + tooltip_text = _("Copy fingerprint"), + css_classes = { "flat" }, + valign = Gtk.Align.CENTER + }; + fingerprint_row.add_suffix (copy_fingerprint_button); + fingerprint_row.activatable_widget = copy_fingerprint_button; + + var details_group = new Adw.PreferencesGroup () { + title = _("Certificate details"), + margin_top = 12 + }; + details_group.add (subject_row); + details_group.add (issuer_row); + details_group.add (not_before_row); + details_group.add (not_after_row); + details_group.add (fingerprint_row); + + var trust_button = new Adw.ButtonRow () { + title = _("Trust certificate") + }; + + var advanced_group = new Adw.PreferencesGroup () { + margin_bottom = 3, + margin_start = 3, + margin_end = 3 + }; + advanced_group.title = _("Advanced"); + + + advanced_group.add (trust_button); + + var advanced_revealer = new Gtk.Revealer () { + transition_type = Gtk.RevealerTransitionType.SLIDE_DOWN, + reveal_child = false, + child = advanced_group + }; + + var advanced_icon = new Gtk.Image.from_icon_name ("go-down-symbolic") { + pixel_size = 12 + }; + + var advanced_label = new Gtk.Label (_("Show advanced options")); + + var advanced_button_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 4) { + halign = CENTER + }; + advanced_button_box.append (advanced_label); + advanced_button_box.append (advanced_icon); + + var advanced_button = new Gtk.Button () { + child = advanced_button_box, + css_classes = { "flat", "caption", "dimmed" }, + halign = CENTER, + margin_top = 12 + }; + + signal_map[advanced_button.clicked.connect (() => { + var revealed = !advanced_revealer.reveal_child; + advanced_revealer.reveal_child = revealed; + advanced_label.label = revealed ? _("Hide advanced options") : _("Show advanced options"); + advanced_icon.icon_name = revealed ? "go-up-symbolic" : "go-down-symbolic"; + })] = advanced_button; + + + signal_map[trust_button.activated.connect (() => { + var trusted = Services.CalDAV.CertificateTrustStore.get_default ().trust_unknown_ca_certificate_for_source (source_id); + if (!trusted) { + popup_toast (_("Could not trust this certificate")); + return; + } + + certificate_trusted (); + preferences_dialog.pop_subpage (); + })] = trust_button; + + var content = new Gtk.Box (Gtk.Orientation.VERTICAL, 0) { + margin_top = 24, + margin_bottom = 24, + margin_start = 12, + margin_end = 12, + vexpand = true, + hexpand = true + }; + content.append (icon); + content.append (title_label); + content.append (body_label); + content.append (details_group); + content.append (advanced_button); + content.append (advanced_revealer); + + var scroll = new Gtk.ScrolledWindow () { + hscrollbar_policy = Gtk.PolicyType.NEVER, + child = content, + vexpand = true, + hexpand = true + }; + + var toolbar_view = new Adw.ToolbarView () { + content = scroll + }; + toolbar_view.add_top_bar (new Adw.HeaderBar ()); + + child = toolbar_view; + } + + private string format_certificate_datetime (GLib.DateTime? value) { + if (value == null) { + return _("Not available"); + } + + return "%s, %s".printf ( + value.format (Utils.Datetime.get_default_date_format (false, true, true)), + value.format (Utils.Datetime.get_default_time_format ()) + ); + } +} diff --git a/src/Dialogs/Preferences/Pages/Accounts/NextcloudSetup.vala b/src/Dialogs/Preferences/Pages/Accounts/NextcloudSetup.vala index 8e4a58cf4..ac851b786 100644 --- a/src/Dialogs/Preferences/Pages/Accounts/NextcloudSetup.vala +++ b/src/Dialogs/Preferences/Pages/Accounts/NextcloudSetup.vala @@ -28,8 +28,7 @@ public class Dialogs.Preferences.Pages.NextcloudSetup : Dialogs.Preferences.Page private Gtk.Button cancel_button; private Gtk.Stack main_stack; - // Advanced Options - private Widgets.IgnoreSSLSwitchRow ignore_ssl_row; + private string source_id; public NextcloudSetup (Adw.PreferencesDialog preferences_dialog, Accounts accounts_page) { Object ( @@ -44,6 +43,8 @@ public class Dialogs.Preferences.Pages.NextcloudSetup : Dialogs.Preferences.Page } construct { + source_id = Util.get_default ().generate_id (); + var icon = new Gtk.Image.from_icon_name ("cloud-outline-thick-symbolic") { pixel_size = 48, css_classes = { "dimmed" } @@ -100,11 +101,6 @@ public class Dialogs.Preferences.Pages.NextcloudSetup : Dialogs.Preferences.Page examples_group.title = _("URL examples"); examples_group.add (examples_box); - // SSL option - ignore_ssl_row = new Widgets.IgnoreSSLSwitchRow (); - - entries_group.add (ignore_ssl_row); - login_button = new Widgets.LoadingButton.with_label (_("Log In")) { margin_top = 24, sensitive = false, @@ -183,6 +179,7 @@ public class Dialogs.Preferences.Pages.NextcloudSetup : Dialogs.Preferences.Page })] = Services.CalDAV.Core.get_default (); destroy.connect (() => { + Services.CalDAV.CertificateTrustStore.get_default ().clear_source_state (source_id); clean_up (); }); } @@ -202,7 +199,7 @@ public class Dialogs.Preferences.Pages.NextcloudSetup : Dialogs.Preferences.Page var nextcloud_provider = new Services.CalDAV.Providers.Nextcloud (); Services.LogService.get_default ().info ("NextcloudSetup", "Starting Nextcloud login flow"); - nextcloud_provider.start_login_flow.begin (server_entry.text, cancellable, ignore_ssl_row.active, (obj, res) => { + nextcloud_provider.start_login_flow.begin (server_entry.text, cancellable, source_id, (obj, res) => { HttpResponse response = nextcloud_provider.start_login_flow.end (res); if (response.status) { @@ -229,10 +226,25 @@ public class Dialogs.Preferences.Pages.NextcloudSetup : Dialogs.Preferences.Page login_button.is_loading = false; cancel_button.visible = false; + var certificate_store = Services.CalDAV.CertificateTrustStore.get_default (); + var tls_failure_context = certificate_store.get_last_tls_failure_for_source (source_id); + if (tls_failure_context != null) { + open_certificate_details_page (source_id, server_entry.text, accounts_page, () => { + on_login_button_clicked (); + }); + return; + } + if (response.error_code == 409) { var toast = new Adw.Toast (response.error.strip ()); toast.timeout = 3; preferences_dialog.add_toast (toast); + } else if (response.error_code == 0 && certificate_store.get_last_tls_failure_for_source (source_id) != null) { + accounts_page.show_message_error ( + 500, + certificate_store.build_tls_failure_message_for_source (source_id), + false + ); } else { accounts_page.show_message_error (response.error_code, response.error.strip ()); } diff --git a/src/Dialogs/Preferences/Pages/Accounts/SourceView.vala b/src/Dialogs/Preferences/Pages/Accounts/SourceView.vala index 88189b298..17e63818b 100644 --- a/src/Dialogs/Preferences/Pages/Accounts/SourceView.vala +++ b/src/Dialogs/Preferences/Pages/Accounts/SourceView.vala @@ -21,6 +21,10 @@ public class Dialogs.Preferences.Pages.SourceView : Dialogs.Preferences.Pages.BasePage { public Objects.Source source { get; construct; } + private Gtk.Box? certificate_sections_box; + private Adw.ActionRow? last_sync_row; + private Gtk.Label? last_sync_label; + private Gtk.Image? last_sync_row_arrow_icon; public SourceView (Adw.PreferencesDialog preferences_dialog, Objects.Source source) { Object ( @@ -83,19 +87,18 @@ public class Dialogs.Preferences.Pages.SourceView : Dialogs.Preferences.Pages.Ba "Activate this setting so that Planify automatically synchronizes with your account account every 15 minutes"); sync_server_row.active = source.sync_server; - var last_sync_label = new Gtk.Label ( - Utils.Datetime.get_relative_date_from_date ( - new GLib.DateTime.from_iso8601 ( - source.last_sync, new GLib.TimeZone.local () - ) - ) - ); + last_sync_label = new Gtk.Label (""); - var last_sync_row = new Adw.ActionRow (); - last_sync_row.activatable = false; + last_sync_row = new Adw.ActionRow (); last_sync_row.title = _("Last Sync"); last_sync_row.add_suffix (last_sync_label); + last_sync_row_arrow_icon = new Gtk.Image.from_icon_name ("go-next-symbolic"); + last_sync_row.add_suffix (last_sync_row_arrow_icon); + last_sync_row_arrow_icon.set_visible (false); + + update_last_sync_row (); + var default_group = new Adw.PreferencesGroup () { margin_top = 24 }; @@ -107,6 +110,8 @@ public class Dialogs.Preferences.Pages.SourceView : Dialogs.Preferences.Pages.Ba default_group.add (last_sync_row); } + certificate_sections_box = new Gtk.Box (Gtk.Orientation.VERTICAL, 0); + var delete_button = new Adw.ButtonRow () { title = _("Delete Source") }; @@ -123,12 +128,13 @@ public class Dialogs.Preferences.Pages.SourceView : Dialogs.Preferences.Pages.Ba }; var delete_stack = new Gtk.Stack () { - margin_top = 24 + margin_top = 24, + margin_bottom = 24 }; - + delete_stack.add_child (delete_group); delete_stack.add_child (delete_spinner); - + var main_content = new Gtk.Box (Gtk.Orientation.VERTICAL, 0) { vexpand = true, hexpand = true @@ -137,9 +143,14 @@ public class Dialogs.Preferences.Pages.SourceView : Dialogs.Preferences.Pages.Ba if (source.source_type != SourceType.LOCAL) { main_content.append (user_box); } - + main_content.append (default_group); + if (source.source_type == SourceType.CALDAV) { + refresh_certificate_sections (); + main_content.append (certificate_sections_box); + } + if (source.source_type != SourceType.LOCAL) { main_content.append (delete_stack); } @@ -151,8 +162,15 @@ public class Dialogs.Preferences.Pages.SourceView : Dialogs.Preferences.Pages.Ba child = main_content }; + var scrolled_window = new Gtk.ScrolledWindow () { + hscrollbar_policy = Gtk.PolicyType.NEVER, + hexpand = true, + vexpand = true, + child = content_clamp + }; + var toolbar_view = new Adw.ToolbarView () { - content = content_clamp + content = scrolled_window }; toolbar_view.add_top_bar (new Adw.HeaderBar ()); @@ -174,10 +192,14 @@ public class Dialogs.Preferences.Pages.SourceView : Dialogs.Preferences.Pages.Ba source.save (); })] = display_entry; + signal_map[last_sync_row.activated.connect (() => { + show_sync_error_details (); + })] = last_sync_row; + signal_map[delete_button.activated.connect (() => { string current_inbox_id = Services.Settings.get_default ().settings.get_string ("local-inbox-project-id"); Objects.Project? current_inbox = Services.Store.instance ().get_project (current_inbox_id); - + if (current_inbox != null && current_inbox.source_id == source.id) { show_inbox_warning_dialog (); return; @@ -206,11 +228,136 @@ public class Dialogs.Preferences.Pages.SourceView : Dialogs.Preferences.Pages.Ba preferences_dialog.pop_subpage (); })] = source; + signal_map[source.sync_failed.connect ((status) => { + update_last_sync_row (); + })] = source; + + signal_map[source.sync_finished.connect (() => { + update_last_sync_row (); + })] = source; + destroy.connect (() => { clean_up (); }); } + private void update_last_sync_row () { + if (last_sync_row == null || last_sync_label == null) { + return; + } + + if (source.sync_status != null) { + last_sync_label.label = _("Failed"); + last_sync_label.add_css_class ("error"); + last_sync_row.activatable = true; + last_sync_row_arrow_icon.set_visible (true); + last_sync_row.subtitle = _("View details"); + return; + } + + last_sync_label.remove_css_class ("error"); + last_sync_row.activatable = false; + last_sync_row_arrow_icon.set_visible (false); + last_sync_row.subtitle = null; + + if (source.last_sync == null || source.last_sync == "") { + last_sync_label.label = _("Never"); + return; + } + + var last_sync_date = new GLib.DateTime.from_iso8601 (source.last_sync, new GLib.TimeZone.local ()); + if (last_sync_date == null) { + last_sync_label.label = _("Unknown"); + return; + } + + last_sync_label.label = Utils.Datetime.get_relative_date_from_date (last_sync_date); + } + + private void show_sync_error_details () { + if (source.sync_status == null) { + return; + } + + var can_review_certificate = source.source_type == SourceType.CALDAV && + source.sync_status.error_type == SyncErrorType.CERTIFICATE_ERROR; + + if (can_review_certificate) { + open_pending_certificate_dialog (); + } else { + var dialog = new Adw.AlertDialog ( + source.sync_status.title, + source.sync_status.description + ); + + dialog.add_response ("close", _("Close")); + dialog.close_response = "close"; + + dialog.present (Planify._instance.main_window); + } + + } + + private void refresh_certificate_sections () { + if (certificate_sections_box == null || source.source_type != SourceType.CALDAV) { + return; + } + + Gtk.Widget? child = certificate_sections_box.get_first_child (); + while (child != null) { + var next = child.get_next_sibling (); + certificate_sections_box.remove (child); + child = next; + } + + var certificates = Services.CalDAV.CertificateTrustStore.get_default ().get_trusted_certificates_for_source (source.id); + if (certificates.size == 0) { + return; + } + + var certificates_group = new Adw.PreferencesGroup () { + title = _("Manually Trusted Certificates") + }; + + foreach (var certificate in certificates) { + var host = certificate.has_key ("host") ? certificate["host"] : _("Unknown host"); + var fingerprint = certificate.has_key ("fingerprint_sha256") ? certificate["fingerprint_sha256"] : _("Unknown fingerprint"); + var certificate_host = host; + + var certificate_row = new Adw.ActionRow () { + title = host, + subtitle = fingerprint, + subtitle_selectable = true + }; + + var delete_certificate_button = new Gtk.Button.from_icon_name ("user-trash-symbolic") { + tooltip_text = _("Untrust Certificate"), + valign = Gtk.Align.CENTER, + css_classes = { "flat", "destructive-action" } + }; + + certificate_row.add_suffix (delete_certificate_button); + + signal_map[delete_certificate_button.clicked.connect (() => { + var deleted = Services.CalDAV.CertificateTrustStore.get_default ().remove_trusted_certificate_for_source (source.id, certificate_host); + if (!deleted) { + popup_toast (_("Could not untrust certificate")); + return; + } + + // Force a new Soup.Session for the next request so removed trust does not linger. + Services.CalDAV.Core.get_default ().remove_client (source.id); + refresh_certificate_sections (); + Services.CalDAV.Core.get_default ().sync.begin (source); + popup_toast (_("Certificate untrusted")); + })] = delete_certificate_button; + + certificates_group.add (certificate_row); + } + + certificate_sections_box.append (certificates_group); + } + private void show_inbox_warning_dialog () { var dialog = new Adw.AlertDialog ( _("Cannot Delete This Source"), @@ -230,4 +377,38 @@ public class Dialogs.Preferences.Pages.SourceView : Dialogs.Preferences.Pages.Ba } }); } + + public bool open_pending_certificate_dialog () { + if (source.source_type != SourceType.CALDAV) { + return false; + } + + var tls_failure_context = Services.CalDAV.CertificateTrustStore.get_default ().get_last_tls_failure_for_source (source.id); + if (tls_failure_context == null || tls_failure_context.flags != GLib.TlsCertificateFlags.UNKNOWN_CA) { + return false; + } + + var trust_page = Dialogs.Preferences.Pages.CertificateDetails.build_for_source ( + preferences_dialog, + source.id, + source.caldav_data.server_url + ); + if (trust_page == null) { + return false; + } + + signal_map[trust_page.certificate_trusted.connect (() => { + var persisted = Services.CalDAV.CertificateTrustStore.get_default ().persist_pending_trusted_certificates_for_source (source.id); + if (!persisted) { + popup_toast (_("Failed to save trusted certificates")); + return; + } + + Services.CalDAV.Core.get_default ().sync.begin (source); + refresh_certificate_sections (); + })] = trust_page; + + preferences_dialog.push_subpage (trust_page); + return true; + } } diff --git a/src/Dialogs/Preferences/Pages/BasePage.vala b/src/Dialogs/Preferences/Pages/BasePage.vala index 45f082acd..0dabc74ae 100644 --- a/src/Dialogs/Preferences/Pages/BasePage.vala +++ b/src/Dialogs/Preferences/Pages/BasePage.vala @@ -23,6 +23,8 @@ public class Dialogs.Preferences.Pages.BasePage : Adw.NavigationPage { public Adw.PreferencesDialog preferences_dialog { get; construct; } public Gee.HashMap signal_map = new Gee.HashMap (); + public delegate void CertificateRetryCallback (); + public virtual void clean_up () { foreach (var entry in signal_map.entries) { entry.value.disconnect (entry.key); @@ -39,4 +41,27 @@ public class Dialogs.Preferences.Pages.BasePage : Adw.NavigationPage { preferences_dialog.add_toast (toast); } } + + protected void open_certificate_details_page ( + string source_id, + string server_url, + Dialogs.Preferences.Pages.Accounts? accounts_page, + CertificateRetryCallback retry_callback + ) { + var trust_page = Dialogs.Preferences.Pages.CertificateDetails.build_for_source ( + preferences_dialog, + source_id, + server_url, + accounts_page + ); + if (trust_page == null) { + return; + } + + signal_map[trust_page.certificate_trusted.connect (() => { + retry_callback (); + })] = trust_page; + + preferences_dialog.push_subpage (trust_page); + } } \ No newline at end of file diff --git a/src/Layouts/SidebarSourceRow.vala b/src/Layouts/SidebarSourceRow.vala index 4a8bc3b4d..d0d31999c 100644 --- a/src/Layouts/SidebarSourceRow.vala +++ b/src/Layouts/SidebarSourceRow.vala @@ -50,6 +50,18 @@ public class Layouts.SidebarSourceRow : Gtk.ListBoxRow { group.add_widget_end (sync_button); sync_button.clicked.connect (() => { + var tls_failure_context = Services.CalDAV.CertificateTrustStore.get_default ().get_last_tls_failure_for_source (source.id); + if (source.source_type == SourceType.CALDAV && tls_failure_context != null && tls_failure_context.flags == GLib.TlsCertificateFlags.UNKNOWN_CA) { + var preferences_dialog = new Dialogs.Preferences.PreferencesWindow (); + preferences_dialog.show_page ("accounts"); + + var source_view = new Dialogs.Preferences.Pages.SourceView (preferences_dialog, source); + preferences_dialog.push_subpage (source_view); + source_view.open_pending_certificate_dialog (); + preferences_dialog.present (Planify._instance.main_window); + return; + } + if (source.source_type == SourceType.TODOIST) { if (source.needs_migration ()) { var preferences_dialog = new Dialogs.Preferences.PreferencesWindow (); diff --git a/src/Widgets/IgnoreSSLSwitchRow.vala b/src/Widgets/IgnoreSSLSwitchRow.vala deleted file mode 100644 index c87800edc..000000000 --- a/src/Widgets/IgnoreSSLSwitchRow.vala +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright © 2025 Alain M. (https://github.com/alainm23/planify) - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * General Public License for more details. - * - * You should have received a copy of the GNU General Public - * License along with this program; if not, write to the - * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, - * Boston, MA 02110-1301 USA - * - * Authored by: Alain M. - */ - -public class Widgets.IgnoreSSLSwitchRow : Adw.ActionRow { - private Gtk.Switch toggle; - - public bool active { - get { return toggle.active; } - set { toggle.active = value; } - } - - public IgnoreSSLSwitchRow () { - title = _("Disable SSL certificate validation"); - subtitle = _("Not recommended – exposes you to security risks"); - - toggle = new Gtk.Switch () { - valign = Gtk.Align.CENTER, - halign = Gtk.Align.END - }; - - add_suffix (toggle); - activatable_widget = toggle; - - toggle.state_set.connect ((state) => { - if (state) { - var dialog = new Adw.AlertDialog ( - _("Warning"), - _("This will disable SSL validation and exposes you to attacks.\nProceed at your own risk") - ); - dialog.add_response ("cancel", _("Cancel")); - dialog.add_response ("accept", _("Continue Anyway")); - dialog.set_response_appearance ("accept", Adw.ResponseAppearance.DESTRUCTIVE); - - dialog.response.connect ((response) => { - if (response == "accept") { - toggle.state = true; - toggle.active = true; - } else { - toggle.state = false; - toggle.active = false; - } - }); - - dialog.present (Planify._instance.main_window); - return true; // prevent immediate state change - } - return false; - }); - - } -} - diff --git a/src/meson.build b/src/meson.build index 13e5894c6..4d67fc3d6 100644 --- a/src/meson.build +++ b/src/meson.build @@ -67,7 +67,6 @@ sources = files( 'Widgets/FilterFlowBox.vala', 'Widgets/FilterFlowBoxChild.vala', 'Widgets/BypassResolveSwitchRow.vala', - 'Widgets/IgnoreSSLSwitchRow.vala', 'Widgets/Attachments.vala', 'Widgets/AttachmentRow.vala', 'Widgets/ItemChangeHistoryRow.vala', @@ -133,6 +132,7 @@ sources = files( 'Dialogs/Preferences/Pages/Accounts/Accounts.vala', 'Dialogs/Preferences/Pages/Accounts/NextcloudSetup.vala', 'Dialogs/Preferences/Pages/Accounts/CalDAVSetup.vala', + 'Dialogs/Preferences/Pages/Accounts/CertificateDetails.vala', 'Dialogs/Preferences/Pages/Accounts/TodoistSetup.vala', 'Dialogs/Preferences/Pages/Accounts/SourceView.vala', 'Dialogs/Preferences/Pages/Accounts/InboxPage.vala' diff --git a/test/test-caldav-integration.vala b/test/test-caldav-integration.vala index 1c0a79d9b..788f0a2ef 100644 --- a/test/test-caldav-integration.vala +++ b/test/test-caldav-integration.vala @@ -15,22 +15,23 @@ async void test_caldav_login_async () throws GLib.Error { var core = Services.CalDAV.Core.get_default (); core.clear (); + var source_id = Util.get_default ().generate_id (); - var dav_endpoint = yield core.resolve_well_known_caldav (new Soup.Session (), server_url); + var dav_endpoint = yield core.resolve_well_known_caldav (new Soup.Session (), server_url, source_id); assert (dav_endpoint != null && dav_endpoint != ""); assert (Uri.parse_scheme (dav_endpoint) != null); message ("Using DAV Endpoint: %s", dav_endpoint); - var calendar_home = yield core.resolve_calendar_home (CalDAVType.GENERIC, dav_endpoint, username, password, cancellable); + var calendar_home = yield core.resolve_calendar_home (CalDAVType.GENERIC, dav_endpoint, username, password, cancellable, source_id); assert (calendar_home != null && calendar_home != ""); assert (Uri.parse_scheme (calendar_home) != null); message ("Calendar Home: %s", calendar_home); - HttpResponse response = yield core.login (CalDAVType.GENERIC, dav_endpoint, username, password, calendar_home, cancellable); + HttpResponse response = yield core.login (CalDAVType.GENERIC, dav_endpoint, username, password, calendar_home, cancellable, source_id); assert (response.status); message ("Login successful"); From 454f7300c00dc75923b674f4e1f1f03799c5d770 Mon Sep 17 00:00:00 2001 From: byquanton <32410361+byquanton@users.noreply.github.com> Date: Tue, 5 May 2026 23:48:41 +0200 Subject: [PATCH 2/5] only remove the timeout if it was present --- core/Objects/Source.vala | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/Objects/Source.vala b/core/Objects/Source.vala index 1e45ae875..71fb38cec 100644 --- a/core/Objects/Source.vala +++ b/core/Objects/Source.vala @@ -176,7 +176,9 @@ public class Objects.Source : Objects.BaseObject { public async void delete_source () { // Remove server_timeout - remove_sync_server (); + if (sync_server) { + remove_sync_server (); + } // Remove DB yield Services.Store.instance ().delete_source (this); From d34e71ce6e471702327084e4879bccbd076757ce Mon Sep 17 00:00:00 2001 From: byquanton <32410361+byquanton@users.noreply.github.com> Date: Tue, 5 May 2026 23:48:56 +0200 Subject: [PATCH 3/5] use username as fallback for caldav displayname --- core/Services/CalDAV/CalDAVClient.vala | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/Services/CalDAV/CalDAVClient.vala b/core/Services/CalDAV/CalDAVClient.vala index 055290aad..d4bcfcf3e 100644 --- a/core/Services/CalDAV/CalDAVClient.vala +++ b/core/Services/CalDAV/CalDAVClient.vala @@ -120,6 +120,10 @@ public class Services.CalDAV.CalDAVClient : Services.CalDAV.WebDAVClient { source.display_name = source.caldav_data.user_displayname; return; } + + if (source.caldav_data.user_displayname == null) { + source.caldav_data.user_displayname = source.caldav_data.username; + } source.display_name = _ ("CalDAV"); } From 06b8bfef525b506658d0bcd2815d67b5e69396b7 Mon Sep 17 00:00:00 2001 From: byquanton <32410361+byquanton@users.noreply.github.com> Date: Tue, 5 May 2026 23:49:05 +0200 Subject: [PATCH 4/5] remove dead code --- core/Widgets/DateTimePicker/DateTimePicker.vala | 5 ----- 1 file changed, 5 deletions(-) diff --git a/core/Widgets/DateTimePicker/DateTimePicker.vala b/core/Widgets/DateTimePicker/DateTimePicker.vala index afe59b8a5..28ff8d51a 100644 --- a/core/Widgets/DateTimePicker/DateTimePicker.vala +++ b/core/Widgets/DateTimePicker/DateTimePicker.vala @@ -23,7 +23,6 @@ public class Widgets.DateTimePicker.DateTimePicker : Gtk.Popover { private Widgets.DateTimePicker.TimePicker time_picker; private Widgets.Calendar.CalendarMonth calendar_view; private Widgets.Calendar.CalendarScroll calendar_scroll_view; - private Widgets.ContextMenu.MenuItem repeat_item; private NoDateButton no_date_button; private OptionButton time_option_button; private OptionButton repeat_option_button; @@ -269,10 +268,6 @@ public class Widgets.DateTimePicker.DateTimePicker : Gtk.Popover { apply_recurrency (RecurrencyType.NONE, 0, null, false); }); - repeat_item.clicked.connect (() => { - show_revealer (repeat_option_revealer); - }); - closed.connect (() => { main_stack.visible_child_name = "main"; }); From 1e1d5aad0483cb3bdf5b91f2e6ecb832fef19bd6 Mon Sep 17 00:00:00 2001 From: byquanton <32410361+byquanton@users.noreply.github.com> Date: Tue, 5 May 2026 23:49:24 +0200 Subject: [PATCH 5/5] move copy button on error view down --- src/Widgets/ErrorView.vala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Widgets/ErrorView.vala b/src/Widgets/ErrorView.vala index 569ce0c91..0afe22165 100644 --- a/src/Widgets/ErrorView.vala +++ b/src/Widgets/ErrorView.vala @@ -105,11 +105,11 @@ public class Widgets.ErrorView : Adw.Bin { }; copy_button = new Gtk.Button.from_icon_name ("edit-copy-symbolic") { - valign = Gtk.Align.START, + valign = Gtk.Align.END, halign = Gtk.Align.END, css_classes = { "flat", "circular" }, tooltip_text = _("Copy error message"), - margin_top = 6, + margin_bottom = 6, margin_end = 6 };