summaryrefslogtreecommitdiff
path: root/pim/cmd/caldavfs/discover.go
blob: 2b5fb23c43ea0517240f1b36a6574b02113f66a1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
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)
}