diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-21 15:38:56 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-21 15:38:56 -0400 |
| commit | e78595096c1cb956602796c6b4b692e58458ff99 (patch) | |
| tree | dd80dca49c7d4df7d3cb57613ff71c9f61351c8b | |
| parent | 4ac1b8161f7206592fa3d8efbf7eabb5c51b7bc6 (diff) | |
| download | dotemacs-e78595096c1cb956602796c6b4b692e58458ff99.tar.gz dotemacs-e78595096c1cb956602796c6b4b692e58458ff99.zip | |
feat(calendar-sync): resolve .ics feed URLs from auth-source
A calendar's .ics feed URL is a secret token, so I'd rather not keep it in a plaintext config file. A calendar can now name a :secret-host, and calendar-sync--calendar-url looks the URL up in auth-source (~/.authinfo.gpg) at sync time. Inline :url still works and wins when both are set, so existing configs are unaffected.
I added 7 tests covering the explicit-url, string-secret, function-secret, precedence, and no-match paths, and switched the .example template to the :secret-host shape.
| -rw-r--r-- | calendar-sync.local.el.example | 20 | ||||
| -rw-r--r-- | modules/calendar-sync.el | 32 | ||||
| -rw-r--r-- | tests/test-calendar-sync--calendar-url.el | 86 |
3 files changed, 128 insertions, 10 deletions
diff --git a/calendar-sync.local.el.example b/calendar-sync.local.el.example index ba84603b..c4646659 100644 --- a/calendar-sync.local.el.example +++ b/calendar-sync.local.el.example @@ -1,8 +1,8 @@ ;;; calendar-sync.local.el.example --- Template for private calendar config -*- lexical-binding: t; -*- ;; Copy this file to `calendar-sync.local.el' (sibling of init.el) and -;; replace the placeholder URLs with your actual private ICS feed -;; addresses. The real file is gitignored; the template is tracked. +;; replace the placeholders with your actual private ICS feeds. The real +;; file is gitignored; the template is tracked. ;; ;; How it works: ;; - `modules/calendar-sync.el' defines `calendar-sync-private-config-file' @@ -13,6 +13,16 @@ ;; - `user-constants' is required earlier in init.el, so `gcal-file', ;; `pcal-file', and `dcal-file' are bound when this file is evaluated. ;; +;; Two ways to give each .ics calendar its feed URL: +;; :secret-host - PREFERRED. An auth-source host whose secret holds the +;; feed URL, looked up in ~/.authinfo.gpg (encrypted at +;; rest, and distributed with your other authinfo entries). +;; The .ics URL is itself a secret token, so it belongs in +;; the encrypted store. Add a line like: +;; machine calendar-google login me password https://...ics +;; :url - the feed URL inline (plaintext in this file). If both +;; are set, :url wins. +;; ;; Where to find the private .ics URL: ;; - Google Calendar: Settings -> Your Calendar -> Integrate calendar -> ;; "Secret address in iCal format" (regenerate if leaked). @@ -21,13 +31,13 @@ (setq calendar-sync-calendars `((:name "google" - :url "https://calendar.google.com/calendar/ical/YOUR_ADDRESS%40gmail.com/private-XXXXXXXXXX/basic.ics" + :secret-host "calendar-google" :file ,gcal-file) (:name "proton" - :url "https://calendar.proton.me/api/calendar/v1/url/XXXXXXXX/calendar.ics?CacheKey=XXXXXXXX&PassphraseKey=XXXXXXXX" + :secret-host "calendar-proton" :file ,pcal-file) (:name "deepsat" - :url "https://calendar.google.com/calendar/ical/YOUR_WORK_ADDRESS%40example.com/private-XXXXXXXXXX/basic.ics" + :secret-host "calendar-deepsat" :file ,dcal-file))) (provide 'calendar-sync.local) diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el index 2fb1df03..9379b427 100644 --- a/modules/calendar-sync.el +++ b/modules/calendar-sync.el @@ -71,6 +71,7 @@ (require 'cl-lib) (require 'subr-x) +(require 'auth-source) (require 'cj-org-text-lib) (defun calendar-sync--log-silently (format-string &rest args) @@ -92,8 +93,13 @@ Each calendar is a plist. Common keys: :file - Output file path for org format :fetcher - Fetch path: \\='ics (default) or \\='api -For the default \\='ics fetcher (Proton, plain .ics feeds): - :url - URL to fetch the .ics file from +For the default \\='ics fetcher (Proton, plain .ics feeds), give the feed +URL one of two ways: + :url - the feed URL inline (plaintext in this file) + :secret-host - an auth-source host whose secret holds the feed URL, + looked up in ~/.authinfo.gpg (encrypted at rest). Prefer + this: the .ics URL is itself a secret token. If both are + set, :url wins. For the \\='api fetcher (Google Calendar, sees per-occurrence response status so OOO auto-declines on recurring events can be filtered): @@ -1497,11 +1503,27 @@ calendar files do not block the interactive Emacs thread." (calendar-sync--sync-calendar-api calendar) (calendar-sync--sync-calendar-ics calendar))) +(defun calendar-sync--calendar-url (calendar) + "Return the .ics feed URL for CALENDAR, or nil if none is configured. +An explicit :url wins. Otherwise :secret-host names an auth-source host +whose stored secret is the URL (kept in auth-source because the .ics URL +is itself a token)." + (or (plist-get calendar :url) + (let ((host (plist-get calendar :secret-host))) + (when host + (let ((secret (plist-get (car (auth-source-search :host host :max 1)) + :secret))) + ;; auth-source's netrc backend returns the secret as a function + (cond ((functionp secret) (funcall secret)) + (secret secret))))))) + (defun calendar-sync--sync-calendar-ics (calendar) - "Sync a single CALENDAR from its :url .ics feed asynchronously. -CALENDAR is a plist with :name, :url, and :file keys." + "Sync a single CALENDAR from its .ics feed asynchronously. +CALENDAR is a plist with :name, :file, and a feed URL resolved by +`calendar-sync--calendar-url' (an explicit :url, or a :secret-host +looked up in auth-source)." (let ((name (plist-get calendar :name)) - (url (plist-get calendar :url)) + (url (calendar-sync--calendar-url calendar)) (file (plist-get calendar :file)) (fetch-start (float-time))) (calendar-sync--set-calendar-state name '(:status syncing)) diff --git a/tests/test-calendar-sync--calendar-url.el b/tests/test-calendar-sync--calendar-url.el new file mode 100644 index 00000000..f18f5b55 --- /dev/null +++ b/tests/test-calendar-sync--calendar-url.el @@ -0,0 +1,86 @@ +;;; test-calendar-sync--calendar-url.el --- Tests for feed URL resolution -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for `calendar-sync--calendar-url', which resolves the .ics +;; feed URL for a calendar plist. An explicit :url wins; otherwise the +;; URL is looked up in auth-source (e.g. ~/.authinfo.gpg) under the +;; calendar's :secret-host. auth-source-search is mocked at the boundary +;; so tests never touch disk or GPG. Covers Normal, Boundary, and Error. + +;;; Code: + +(require 'ert) +(require 'calendar-sync) + +(defmacro test-cs-url--with-auth (result &rest body) + "Run BODY with `auth-source-search' stubbed to return RESULT. +Records each call's :host argument into `calls', a binding BODY can read." + (declare (indent 1)) + `(let ((calls '())) + (cl-letf (((symbol-function 'auth-source-search) + (lambda (&rest args) + (push (plist-get args :host) calls) + ,result))) + ,@body))) + +;;; Normal + +(ert-deftest test-calendar-sync--calendar-url-normal-explicit-url () + "Normal: an explicit :url is returned verbatim." + (test-cs-url--with-auth nil + (should (equal "https://example.com/feed.ics" + (calendar-sync--calendar-url + '(:name "x" :url "https://example.com/feed.ics")))) + ;; auth-source must not be consulted when :url is present + (should (null calls)))) + +(ert-deftest test-calendar-sync--calendar-url-normal-string-secret () + "Normal: :secret-host resolves via auth-source with a string secret." + (test-cs-url--with-auth (list (list :host "calendar-google" + :secret "https://g.example/basic.ics")) + (should (equal "https://g.example/basic.ics" + (calendar-sync--calendar-url + '(:name "google" :secret-host "calendar-google")))) + (should (equal '("calendar-google") calls)))) + +(ert-deftest test-calendar-sync--calendar-url-normal-function-secret () + "Normal: a function-valued :secret is funcalled (the netrc backend's form)." + (test-cs-url--with-auth (list (list :host "calendar-proton" + :secret (lambda () "https://p.example/cal.ics"))) + (should (equal "https://p.example/cal.ics" + (calendar-sync--calendar-url + '(:name "proton" :secret-host "calendar-proton")))))) + +;;; Boundary + +(ert-deftest test-calendar-sync--calendar-url-boundary-explicit-url-wins () + "Boundary: when both :url and :secret-host are set, :url wins and +auth-source is never consulted." + (test-cs-url--with-auth (list (list :host "h" :secret "from-authinfo")) + (should (equal "from-url" + (calendar-sync--calendar-url + '(:name "x" :url "from-url" :secret-host "h")))) + (should (null calls)))) + +;;; Error + +(ert-deftest test-calendar-sync--calendar-url-error-neither-key () + "Error: a calendar with neither :url nor :secret-host yields nil." + (test-cs-url--with-auth (list (list :host "h" :secret "should-not-reach")) + (should (null (calendar-sync--calendar-url '(:name "x" :file "f")))) + (should (null calls)))) + +(ert-deftest test-calendar-sync--calendar-url-error-no-match () + "Error: :secret-host with no auth-source match yields nil." + (test-cs-url--with-auth nil + (should (null (calendar-sync--calendar-url + '(:name "x" :secret-host "calendar-missing")))))) + +(ert-deftest test-calendar-sync--calendar-url-error-match-without-secret () + "Error: a match lacking a :secret yields nil, not an error." + (test-cs-url--with-auth (list (list :host "calendar-google")) + (should (null (calendar-sync--calendar-url + '(:name "x" :secret-host "calendar-google")))))) + +(provide 'test-calendar-sync--calendar-url) +;;; test-calendar-sync--calendar-url.el ends here |
