summaryrefslogtreecommitdiffstats
path: root/comm/calendar/providers/caldav/CalDavProvider.jsm
blob: 940e64337d00f094ff257b68207c984e377dbb8a (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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

var EXPORTED_SYMBOLS = ["CalDavProvider"];

var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
var { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm");

var { CalDavPropfindRequest } = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm");

var { CalDavDetectionSession } = ChromeUtils.import("resource:///modules/caldav/CalDavSession.jsm");

// NOTE: This module should not be loaded directly, it is available when
// including calUtils.jsm under the cal.provider.caldav namespace.

/**
 * @implements {calICalendarProvider}
 */
var CalDavProvider = {
  QueryInterface: ChromeUtils.generateQI(["calICalendarProvider"]),

  get type() {
    return "caldav";
  },

  get displayName() {
    return cal.l10n.getCalString("caldavName");
  },

  get shortName() {
    return "CalDAV";
  },

  deleteCalendar(aCalendar, aListener) {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  },

  async detectCalendars(
    username,
    password,
    location = null,
    savePassword = false,
    extraProperties = {}
  ) {
    let uri = cal.provider.detection.locationToUri(location);
    if (!uri) {
      throw new Error("Could not infer location from username");
    }

    let detector = new CalDavDetector(username, password, savePassword);

    for (let method of [
      "attemptGoogleOauth",
      "attemptLocation",
      "dnsSRV",
      "wellKnown",
      "attemptRoot",
    ]) {
      try {
        cal.LOG(`[CalDavProvider] Trying to detect calendar using ${method} method`);
        let calendars = await detector[method](uri);
        if (calendars) {
          return calendars;
        }
      } catch (e) {
        // e may be an Error object or a response object like CalDavSimpleResponse.
        // It can even be a string, as with the OAuth2 error below.
        let message = `[CalDavProvider] Could not detect calendar using method ${method}`;

        let errorDetails = err =>
          ` - ${err.fileName || err.filename}:${err.lineNumber}: ${err} - ${err.stack}`;

        let responseDetails = response => ` - HTTP response status ${response.status}`;

        // A special thing the OAuth2 code throws.
        if (e == '{ "error": "cancelled"}') {
          cal.WARN(message + ` - OAuth2 '${e}'`);
          throw new cal.provider.detection.CanceledError("OAuth2 prompt canceled");
        }

        // We want to pass on any autodetect errors that will become results.
        if (e instanceof cal.provider.detection.Error) {
          cal.WARN(message + errorDetails(e));
          throw e;
        }

        // Sometimes e is a CalDavResponseBase that is an auth error, so throw it.
        if (e.authError) {
          cal.WARN(message + responseDetails(e));
          throw new cal.provider.detection.AuthFailedError();
        }

        if (e instanceof Error) {
          cal.WARN(message + errorDetails(e));
        } else if (typeof e.status == "number") {
          cal.WARN(message + responseDetails(e));
        } else {
          cal.WARN(message);
        }
      }
    }
    return [];
  },
};

/**
 * Used by the CalDavProvider to detect CalDAV calendars for a given username,
 * password, location, etc.
 */
class CalDavDetector {
  /**
   * Create a new caldav detector.
   *
   * @param {string} username - A username.
   * @param {string} password - A password.
   * @param {boolean} savePassword - Whether to save the password or not.
   */
  constructor(username, password, savePassword) {
    this.username = username;
    this.session = new CalDavDetectionSession(username, password, savePassword);
  }

  /**
   * Attempt to detect calendars at the given location.
   *
   * @param {nsIURI} location - The location to attempt.
   * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
   */
  attemptLocation(location) {
    if (location.filePath == "/") {
      // The location is the root, don't try to detect the collection, let the
      // other handlers take care of it.
      return Promise.resolve(null);
    }
    return this.detectCollection(location);
  }

  /**
   * Attempt to detect calendars at the given location using DNS lookups.
   *
   * @param {nsIURI} location - The location to attempt.
   * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
   */
  async dnsSRV(location) {
    if (location.filePath != "/") {
      // If there is already a path specified, then no need to use DNS lookups.
      return null;
    }

    let dnshost = location.host;
    let secure = location.schemeIs("http") ? "" : "s";
    let dnsres = await DNS.srv(`_caldav${secure}._tcp.${dnshost}`);

    if (!dnsres.length) {
      let basedomain;
      try {
        basedomain = Services.eTLD.getBaseDomain(location);
      } catch (e) {
        // If we can't get a base domain just skip it.
      }

      if (basedomain && basedomain != location.host) {
        cal.LOG(`[CalDavProvider] ${location.host} has no SRV entry, trying ${basedomain}`);
        dnsres = await DNS.srv(`_caldav${secure}._tcp.${basedomain}`);
        dnshost = basedomain;
      }
    }

    if (!dnsres.length) {
      return null;
    }
    dnsres.sort((a, b) => a.prio - b.prio || b.weight - a.weight);

    // Determine path from TXT, if available.
    let pathres = await DNS.txt(`_caldav${secure}._tcp.${dnshost}`);
    pathres.sort((a, b) => a.prio - b.prio || b.weight - a.weight);
    pathres = pathres.filter(result => result.data.startsWith("path="));
    // Get the string after `path=`.
    let path = pathres.length ? pathres[0].data.substr(5) : "";

    let calendars;
    if (path) {
      // If the server has SRV and TXT entries, we already have a full context path to test.
      let uri = `http${secure}://${dnsres[0].host}:${dnsres[0].port}${path}`;
      cal.LOG(`[CalDavProvider] Trying ${uri} from SRV and TXT response`);
      calendars = await this.detectCollection(Services.io.newURI(uri));
    }

    if (!calendars) {
      // Either the txt record doesn't point to a path (in which case we need to repeat with
      // well-known), or no calendars could be detected at that location (in which case we
      // need to repeat with well-known).

      let baseloc = Services.io.newURI(
        `http${secure}://${dnsres[0].host}:${dnsres[0].port}/.well-known/caldav`
      );
      cal.LOG(`[CalDavProvider] Trying ${baseloc.spec} from SRV response with .well-known`);

      calendars = await this.detectCollection(baseloc);
    }

    return calendars;
  }

  /**
   * Attempt to detect calendars using a `.well-known` URI.
   *
   * @param {nsIURI} location - The location to attempt.
   * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
   */
  async wellKnown(location) {
    let wellKnownUri = Services.io.newURI("/.well-known/caldav", null, location);
    cal.LOG(`[CalDavProvider] Trying .well-known URI without dns at ${wellKnownUri.spec}`);
    return this.detectCollection(wellKnownUri);
  }

  /**
   * Attempt to detect calendars using a root ("/") URI.
   *
   * @param {nsIURI} location - The location to attempt.
   * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
   */
  attemptRoot(location) {
    let rootUri = Services.io.newURI("/", null, location);
    return this.detectCollection(rootUri);
  }

  /**
   * Attempt to detect calendars using Google OAuth.
   *
   * @param {nsIURI} calURI - The location to attempt.
   * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
   */
  async attemptGoogleOauth(calURI) {
    let usesGoogleOAuth = cal.provider.detection.googleOAuthDomains.has(calURI.host);
    if (!usesGoogleOAuth) {
      // Not using Google OAuth that we know of, but we could check the mx entry.
      // If mail is handled by Google then this is likely a Google Apps domain.
      let mxRecords = await DNS.mx(calURI.host);
      usesGoogleOAuth = mxRecords.some(r => /\bgoogle\.com$/.test(r.host));
    }

    if (usesGoogleOAuth) {
      // If we were given a full URL to a calendar, try to use it.
      let spec = this.username
        ? `https://apidata.googleusercontent.com/caldav/v2/${encodeURIComponent(
            this.username
          )}/user`
        : calURI.spec;
      let uri = Services.io.newURI(spec);
      return this.handlePrincipal(uri);
    }
    return null;
  }

  /**
   * Utility function to detect whether a calendar collection exists at a given
   * location and return it if it exists.
   *
   * @param {nsIURI} location - The location to attempt.
   * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
   */
  async detectCollection(location) {
    let props = [
      "D:resourcetype",
      "D:owner",
      "D:displayname",
      "D:current-user-principal",
      "D:current-user-privilege-set",
      "A:calendar-color",
      "C:calendar-home-set",
    ];

    cal.LOG(`[CalDavProvider] Checking collection type at ${location.spec}`);
    let request = new CalDavPropfindRequest(this.session, null, location, props);

    // `request.commit()` can throw; errors should be caught by calling functions.
    let response = await request.commit();
    let target = response.uri;

    if (response.authError) {
      throw new cal.provider.detection.AuthFailedError();
    } else if (!response.ok) {
      cal.LOG(`[CalDavProvider] ${target.spec} did not respond properly to PROPFIND`);
      return null;
    }

    let resprops = response.firstProps;
    let resourceType = resprops["D:resourcetype"];

    if (resourceType.has("C:calendar")) {
      cal.LOG(`[CalDavProvider] ${target.spec} is a calendar`);
      return [this.handleCalendar(target, resprops)];
    } else if (resourceType.has("D:principal")) {
      cal.LOG(`[CalDavProvider] ${target.spec} is a principal, looking at home set`);
      let homeSet = resprops["C:calendar-home-set"];
      let homeSetUrl = Services.io.newURI(homeSet, null, target);
      return this.handleHomeSet(homeSetUrl);
    } else if (resprops["D:current-user-principal"]) {
      cal.LOG(
        `[CalDavProvider] ${target.spec} is something else, looking at current-user-principal`
      );
      let principalUrl = Services.io.newURI(resprops["D:current-user-principal"], null, target);
      return this.handlePrincipal(principalUrl);
    } else if (resprops["D:owner"]) {
      cal.LOG(`[CalDavProvider] ${target.spec} is something else, looking at collection owner`);
      let principalUrl = Services.io.newURI(resprops["D:owner"], null, target);
      return this.handlePrincipal(principalUrl);
    }

    return null;
  }

  /**
   * Utility function to make a new attempt to detect calendars after the
   * previous PROPFIND results contained either "D:current-user-principal"
   * or "D:owner" props.
   *
   * @param {nsIURI} location - The location to attempt.
   * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
   */
  async handlePrincipal(location) {
    let props = ["D:resourcetype", "C:calendar-home-set"];
    let request = new CalDavPropfindRequest(this.session, null, location, props);
    cal.LOG(`[CalDavProvider] Checking collection type at ${location.spec}`);

    // `request.commit()` can throw; errors should be caught by calling functions.
    let response = await request.commit();
    let homeSets = response.firstProps["C:calendar-home-set"];
    let target = response.uri;

    if (response.authError) {
      throw new cal.provider.detection.AuthFailedError();
    } else if (!response.firstProps["D:resourcetype"].has("D:principal")) {
      cal.LOG(`[CalDavProvider] ${target.spec} is not a principal collection`);
      return null;
    } else if (homeSets) {
      let calendars = [];
      for (let homeSet of homeSets) {
        cal.LOG(`[CalDavProvider] ${target.spec} has a home set at ${homeSet}, checking that`);
        let homeSetUrl = Services.io.newURI(homeSet, null, target);
        let discoveredCalendars = await this.handleHomeSet(homeSetUrl);
        if (discoveredCalendars) {
          calendars.push(...discoveredCalendars);
        }
      }
      return calendars.length ? calendars : null;
    } else {
      cal.LOG(`[CalDavProvider] ${target.spec} doesn't have a home set`);
      return null;
    }
  }

  /**
   * Utility function to make a new attempt to detect calendars after the
   * previous PROPFIND results contained a "C:calendar-home-set" prop.
   *
   * @param {nsIURI} location - The location to attempt.
   * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
   */
  async handleHomeSet(location) {
    let props = [
      "D:resourcetype",
      "D:displayname",
      "D:current-user-privilege-set",
      "A:calendar-color",
    ];
    let request = new CalDavPropfindRequest(this.session, null, location, props, 1);

    // `request.commit()` can throw; errors should be caught by calling functions.
    let response = await request.commit();
    let target = response.uri;

    if (response.authError) {
      throw new cal.provider.detection.AuthFailedError();
    }

    let calendars = [];
    for (let [href, resprops] of Object.entries(response.data)) {
      if (resprops["D:resourcetype"].has("C:calendar")) {
        let hrefUri = Services.io.newURI(href, null, target);
        calendars.push(this.handleCalendar(hrefUri, resprops));
      }
    }
    cal.LOG(`[CalDavProvider] ${target.spec} is a home set, found ${calendars.length} calendars`);

    return calendars.length ? calendars : null;
  }

  /**
   * Set up and return a new caldav calendar object.
   *
   * @param {nsIURI} uri - The location of the calendar.
   * @param {Set} props - The calendar properties parsed from the
   *                                  response.
   * @returns {calICalendar} A new calendar.
   */
  handleCalendar(uri, props) {
    let displayName = props["D:displayname"];
    let color = props["A:calendar-color"];
    if (!displayName) {
      let fileName = decodeURI(uri.spec).split("/").filter(Boolean).pop();
      displayName = fileName || uri.spec;
    }

    // Some servers provide colors as an 8-character hex string. Strip the alpha component.
    color = color?.replace(/^(#[0-9A-Fa-f]{6})[0-9A-Fa-f]{2}$/, "$1");

    let calendar = cal.manager.createCalendar("caldav", uri);
    calendar.setProperty("color", color || cal.view.hashColor(uri.spec));
    calendar.name = displayName;
    calendar.id = cal.getUUID();
    calendar.setProperty("username", this.username);
    calendar.wrappedJSObject.session = this.session.toBaseSession();

    // Attempt to discover if the user is allowed to write to this calendar.
    let privs = props["D:current-user-privilege-set"];
    if (privs && privs instanceof Set) {
      calendar.readOnly = !["D:write", "D:write-content", "D:write-properties", "D:all"].some(
        priv => privs.has(priv)
      );
    }
    return calendar;
  }
}