From 08155ead7bf80308b4cb67063cc91e3e3679ce16 Mon Sep 17 00:00:00 2001 From: Calvin Morrison Date: Sat, 22 Aug 2026 14:04:17 -0400 Subject: pim: a caldav backend, and a partstat that refuses to lie caldav/fs is the second backend, and it cost ~330 lines: discovery, a report for the window, ETags to decide whether anything moved. The event model, recurrence expansion, the tree, ctl, query, alarm and changed all came from lib/cal untouched, which is what the split was for. go-webdav hands back *ical.Calendar from the same library lib/cal parses with, so FromCalendars takes it straight in. Two interfaces, both optional. Identity says whose calendar this is, because in iTIP your identity is the mailto: in your own ATTENDEE line and nothing else can tell which attendee is you. RSVPer says the backend can answer an invitation -- meaning both halves of it, updating your copy and telling the organiser. A backend that can only do one should not implement it, so partstat is 0444 on a published .ics and writing to it fails rather than half-working. Two bugs worth naming. go-webdav resolves paths with path.Join, which drops a trailing slash: pointed at /dav/ it asks about /dav, and Cyrus answers 405 for that spelling and 207 for the other, so the first PROPFIND is done by hand. And an absolute DAV href replaces the endpoint's path rather than extending it; appending gave /dav/dav/calendars/..., a 404, and a probe reading 404 as "no" hid calendar-auto-schedule, which fastmail does in fact offer. pimup brings mail and the calendars up and mounts them; riostart runs it before opening any window, since a mount only exists in the namespace that made it. rio does not run riostart by itself: it is the argument to -i. Co-Authored-By: Claude Opus 5 --- pim/cmd/caldavfs/discover.go | 130 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 pim/cmd/caldavfs/discover.go (limited to 'pim/cmd/caldavfs/discover.go') diff --git a/pim/cmd/caldavfs/discover.go b/pim/cmd/caldavfs/discover.go new file mode 100644 index 0000000..2b5fb23 --- /dev/null +++ b/pim/cmd/caldavfs/discover.go @@ -0,0 +1,130 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/emersion/go-webdav" + + "github.com/emersion/go-webdav/caldav" + "pim/lib/cal" +) + +// dial builds an authenticated client and finds the calendar to serve. +// +// The walk is the one CalDAV prescribes: the endpoint tells you your +// principal, the principal tells you where your calendars live, and +// that collection lists them. Naming a calendar by its display name +// beats hardcoding a path, which servers are free to change. +func dial(endpoint, user, pass, want string) (*caldav.Client, caldav.Calendar, bool, error) { + var zero caldav.Calendar + + hc := webdav.HTTPClientWithBasicAuth(nil, user, pass) + c, err := caldav.NewClient(hc, endpoint) + if err != nil { + return nil, zero, false, err + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + principal, err := findPrincipal(endpoint, user, pass) + if err != nil { + return nil, zero, false, fmt.Errorf("principal: %v", err) + } + home, err := c.FindCalendarHomeSet(ctx, principal) + if err != nil { + return nil, zero, false, fmt.Errorf("home set: %v", err) + } + cals, err := c.FindCalendars(ctx, home) + if err != nil { + return nil, zero, false, fmt.Errorf("calendars: %v", err) + } + if len(cals) == 0 { + return nil, zero, false, fmt.Errorf("no calendars under %s", home) + } + + sched := autoSchedule(endpoint, user, pass, home) + cal.Warnf("home %s, auto-schedule %v", home, sched) + + if want == "" { + // No choice made: list them and refuse, rather than pick one + // and have it silently be the wrong calendar. + var names []string + for _, cl := range cals { + names = append(names, cl.Name) + } + return nil, zero, sched, fmt.Errorf("which calendar? -C one of: %s", + strings.Join(names, ", ")) + } + for _, cl := range cals { + if cl.Name == want || cl.Path == want { + return c, cl, sched, nil + } + } + return nil, zero, sched, fmt.Errorf("no calendar named %q", want) +} + +// autoSchedule asks whether the server sends iMIP on our behalf. A +// server that does means writing a PARTSTAT both updates our copy and +// tells the organiser; a server that does not means we must send the +// mail ourselves. +func autoSchedule(endpoint, user, pass, path string) bool { + req, err := newRequest("OPTIONS", endpoint, path) + if err != nil { + return false + } + req.SetBasicAuth(user, pass) + resp, err := httpDo(req) + if err != nil { + return false + } + defer resp.Body.Close() + for _, v := range resp.Header.Values("DAV") { + for _, f := range strings.Split(v, ",") { + if strings.TrimSpace(f) == "calendar-auto-schedule" { + return true + } + } + } + return false +} + +// urlJoin resolves a DAV href against the endpoint. +// +// An href from the server is absolute on that server: it replaces the +// endpoint's path rather than extending it. Appending it instead gives +// /dav/dav/calendars/... which answers 404, and a probe that reads a +// 404 as "no" reports a capability the server actually has. +func urlJoin(endpoint, href string) string { + base, err := url.Parse(endpoint) + if err != nil { + return href + } + ref, err := url.Parse(href) + if err != nil { + return href + } + return base.ResolveReference(ref).String() +} + +// readSecret reads a password from a file, so it stays out of argv where +// ps(1) would show it. +func readSecret(path string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + return strings.Trim(line, `"'`), nil + } + return "", fmt.Errorf("%s: empty", path) +} -- cgit v1.2.3