1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
4
5const EXPORTED_SYMBOLS = ["CardDAVDirectory"];
6
7const { XPCOMUtils } = ChromeUtils.import(
8  "resource://gre/modules/XPCOMUtils.jsm"
9);
10
11XPCOMUtils.defineLazyModuleGetters(this, {
12  CardDAVUtils: "resource:///modules/CardDAVUtils.jsm",
13  clearInterval: "resource://gre/modules/Timer.jsm",
14  NotificationCallbacks: "resource:///modules/CardDAVUtils.jsm",
15  OAuth2Module: "resource:///modules/OAuth2Module.jsm",
16  OAuth2Providers: "resource:///modules/OAuth2Providers.jsm",
17  Services: "resource://gre/modules/Services.jsm",
18  setInterval: "resource://gre/modules/Timer.jsm",
19  setTimeout: "resource://gre/modules/Timer.jsm",
20  SQLiteDirectory: "resource:///modules/SQLiteDirectory.jsm",
21  VCardUtils: "resource:///modules/VCardUtils.jsm",
22});
23
24const PREFIX_BINDINGS = {
25  card: "urn:ietf:params:xml:ns:carddav",
26  cs: "http://calendarserver.org/ns/",
27  d: "DAV:",
28};
29const NAMESPACE_STRING = Object.entries(PREFIX_BINDINGS)
30  .map(([prefix, url]) => `xmlns:${prefix}="${url}"`)
31  .join(" ");
32
33const log = console.createInstance({
34  prefix: "carddav.sync",
35  maxLogLevel: "Warn",
36  maxLogLevelPref: "carddav.sync.loglevel",
37});
38
39/**
40 * Adds CardDAV sync to SQLiteDirectory.
41 */
42class CardDAVDirectory extends SQLiteDirectory {
43  /** nsIAbDirectory */
44
45  init(uri) {
46    super.init(uri);
47
48    // If this directory is configured, start sync'ing with the server in 30s.
49    // Don't do this immediately, as this code runs at start-up and could
50    // impact performance if there are lots of changes to process.
51    if (this._serverURL && this.getIntValue("carddav.syncinterval", 30) > 0) {
52      this._syncTimer = setTimeout(() => this.syncWithServer(), 30000);
53    }
54
55    let uidsToSync = this.getStringValue("carddav.uidsToSync", "");
56    if (uidsToSync) {
57      this._uidsToSync = new Set(uidsToSync.split(" ").filter(Boolean));
58      this.setStringValue("carddav.uidsToSync", "");
59      log.debug(`Retrieved list of cards to sync: ${uidsToSync}`);
60    } else {
61      this._uidsToSync = new Set();
62    }
63
64    let hrefsToRemove = this.getStringValue("carddav.hrefsToRemove", "");
65    if (hrefsToRemove) {
66      this._hrefsToRemove = new Set(hrefsToRemove.split(" ").filter(Boolean));
67      this.setStringValue("carddav.hrefsToRemove", "");
68      log.debug(`Retrieved list of cards to remove: ${hrefsToRemove}`);
69    } else {
70      this._hrefsToRemove = new Set();
71    }
72  }
73  async cleanUp() {
74    await super.cleanUp();
75
76    if (this._syncTimer) {
77      clearInterval(this._syncTimer);
78      this._syncTimer = null;
79    }
80
81    if (this._uidsToSync.size) {
82      let uidsToSync = [...this._uidsToSync].join(" ");
83      this.setStringValue("carddav.uidsToSync", uidsToSync);
84      log.debug(`Stored list of cards to sync: ${uidsToSync}`);
85    }
86    if (this._hrefsToRemove.size) {
87      let hrefsToRemove = [...this._hrefsToRemove].join(" ");
88      this.setStringValue("carddav.hrefsToRemove", hrefsToRemove);
89      log.debug(`Stored list of cards to remove: ${hrefsToRemove}`);
90    }
91  }
92
93  get propertiesChromeURI() {
94    return "chrome://messenger/content/addressbook/abCardDAVProperties.xhtml";
95  }
96  get dirType() {
97    return Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE;
98  }
99  get supportsMailingLists() {
100    return false;
101  }
102
103  modifyCard(card) {
104    // Well this is awkward. Because it's defined in nsIAbDirectory,
105    // modifyCard must not be async, but we need to do async operations.
106
107    if (this._readOnly) {
108      throw new Components.Exception(
109        "Directory is read-only",
110        Cr.NS_ERROR_FAILURE
111      );
112    }
113
114    // We've thrown the most likely exception synchronously, now do the rest.
115
116    this._modifyCard(card);
117  }
118  async _modifyCard(card) {
119    let oldProperties = this.loadCardProperties(card.UID);
120
121    let newProperties = new Map();
122    for (let { name, value } of card.properties) {
123      newProperties.set(name, value);
124    }
125
126    let sendSucceeded;
127    try {
128      sendSucceeded = await this._sendCardToServer(card);
129    } catch (ex) {
130      Cu.reportError(ex);
131      super.modifyCard(card);
132      return;
133    }
134
135    if (!sendSucceeded) {
136      // _etag and _vCard properties have now been updated. Work out what
137      // properties changed on the server, and copy them to `card`, but only
138      // if they haven't also changed on the client.
139      let serverCard = VCardUtils.vCardToAbCard(card.getProperty("_vCard", ""));
140      for (let { name, value } of serverCard.properties) {
141        if (
142          value != newProperties.get(name) &&
143          newProperties.get(name) == oldProperties.get(name)
144        ) {
145          card.setProperty(name, value);
146        }
147      }
148
149      // Send the card back to the server. This time, the ETag matches what's
150      // on the server, so this should succeed.
151      await this._sendCardToServer(card);
152    }
153  }
154  deleteCards(cards) {
155    super.deleteCards(cards);
156    this._deleteCards(cards);
157  }
158  async _deleteCards(cards) {
159    for (let card of cards) {
160      try {
161        await this._deleteCardFromServer(card);
162      } catch (ex) {
163        Cu.reportError(ex);
164        break;
165      }
166    }
167
168    for (let card of cards) {
169      this._uidsToSync.delete(card.UID);
170    }
171  }
172  dropCard(card, needToCopyCard) {
173    // Ideally, we'd not add the card until it was on the server, but we have
174    // to return newCard synchronously.
175    let newCard = super.dropCard(card, needToCopyCard);
176    this._sendCardToServer(newCard).catch(Cu.reportError);
177    return newCard;
178  }
179  addMailList() {
180    throw Components.Exception(
181      "CardDAVDirectory does not implement addMailList",
182      Cr.NS_ERROR_NOT_IMPLEMENTED
183    );
184  }
185  setIntValue(name, value) {
186    super.setIntValue(name, value);
187
188    // Capture changes to the sync interval from the UI.
189    if (name == "carddav.syncinterval") {
190      this._scheduleNextSync();
191    }
192  }
193
194  /** CardDAV specific */
195  _syncInProgress = false;
196  _syncTimer = null;
197
198  get _serverURL() {
199    return this.getStringValue("carddav.url", "");
200  }
201  get _syncToken() {
202    return this.getStringValue("carddav.token", "");
203  }
204  set _syncToken(value) {
205    this.setStringValue("carddav.token", value);
206  }
207
208  /**
209   * Wraps CardDAVUtils.makeRequest, resolving path this directory's server
210   * URL, and providing a mechanism to give a username and password specific
211   * to this directory.
212   *
213   * @param {String} path - A path relative to the server URL.
214   * @param {Object} details - See CardDAVUtils.makeRequest.
215   * @return {Promise<Object>} - See CardDAVUtils.makeRequest.
216   */
217  async _makeRequest(path, details = {}) {
218    let serverURI = Services.io.newURI(this._serverURL);
219    let uri = serverURI.resolve(path);
220
221    if (!("_oAuth" in this)) {
222      if (OAuth2Providers.getHostnameDetails(serverURI.host)) {
223        this._oAuth = new OAuth2Module();
224        this._oAuth.initFromABDirectory(this, serverURI.host);
225      } else {
226        this._oAuth = null;
227      }
228    }
229    details.oAuth = this._oAuth;
230
231    let username = this.getStringValue("carddav.username", "");
232    let callbacks = new NotificationCallbacks(username);
233    details.callbacks = callbacks;
234
235    details.userContextId =
236      this._userContextId ?? CardDAVUtils.contextForUsername(username);
237
238    let response = await CardDAVUtils.makeRequest(uri, details);
239    if (
240      details.expectedStatuses &&
241      !details.expectedStatuses.includes(response.status)
242    ) {
243      throw Components.Exception(
244        `Incorrect response from server: ${response.status} ${response.statusText}`,
245        Cr.NS_ERROR_FAILURE
246      );
247    }
248
249    if (callbacks.shouldSaveAuth) {
250      // The user was prompted for a username and password. Save the response.
251      this.setStringValue("carddav.username", callbacks.authInfo?.username);
252      callbacks.saveAuth();
253    }
254    return response;
255  }
256
257  /**
258   * Gets or creates the path for storing this card on the server. Cards that
259   * already exist on the server have this value in the _href property.
260   *
261   * @param {nsIAbCard} card
262   * @return {String}
263   */
264  _getCardHref(card) {
265    let href = card.getProperty("_href", "");
266    if (href) {
267      return href;
268    }
269    href = Services.io.newURI(this._serverURL).resolve(`${card.UID}.vcf`);
270    return new URL(href).pathname;
271  }
272
273  _multigetRequest(hrefsToFetch) {
274    hrefsToFetch = hrefsToFetch.map(
275      href => `      <d:href>${xmlEncode(href)}</d:href>`
276    );
277    let data = `<card:addressbook-multiget ${NAMESPACE_STRING}>
278      <d:prop>
279        <d:getetag/>
280        <card:address-data/>
281      </d:prop>
282      ${hrefsToFetch.join("\n")}
283    </card:addressbook-multiget>`;
284
285    return this._makeRequest("", {
286      method: "REPORT",
287      body: data,
288      headers: {
289        Depth: 1,
290      },
291      expectedStatuses: [207],
292    });
293  }
294
295  /**
296   * Performs a multiget request for the provided hrefs, and adds each response
297   * to the directory, adding or modifying as necessary.
298   *
299   * @param {String[]} hrefsToFetch - The href of each card to be requested.
300   */
301  async _fetchAndStore(hrefsToFetch) {
302    if (hrefsToFetch.length == 0) {
303      return;
304    }
305
306    let response = await this._multigetRequest(hrefsToFetch);
307
308    // If this directory is set to read-only, the following operations would
309    // throw NS_ERROR_FAILURE, but sync operations are allowed on a read-only
310    // directory, so set this._overrideReadOnly to avoid the exception.
311    //
312    // Do not use await while it is set, and use a try/finally block to ensure
313    // it is cleared.
314
315    try {
316      this._overrideReadOnly = true;
317      for (let { href, properties } of this._readResponse(response.dom)) {
318        if (!properties) {
319          continue;
320        }
321
322        let etag = properties.querySelector("getetag")?.textContent;
323        let vCard = normalizeLineEndings(
324          properties.querySelector("address-data")?.textContent
325        );
326
327        let abCard = VCardUtils.vCardToAbCard(vCard);
328        abCard.setProperty("_etag", etag);
329        abCard.setProperty("_href", href);
330        abCard.setProperty("_vCard", vCard);
331
332        if (!this.cards.has(abCard.UID)) {
333          super.dropCard(abCard, false);
334        } else if (this.loadCardProperties(abCard.UID).get("_etag") != etag) {
335          super.modifyCard(abCard);
336        }
337      }
338    } finally {
339      this._overrideReadOnly = false;
340    }
341  }
342
343  /**
344   * Reads a multistatus response, yielding once for each response element.
345   *
346   * @param {Document} dom - as returned by CardDAVUtils.makeRequest.
347   * @yields {Object} - An object representing a single <response> element
348   *     from the document:
349   *     - href, the href of the object represented
350   *     - notFound, if a 404 status applies to this response
351   *     - properties, the <prop> element, if any, containing properties
352   *         of the object represented
353   */
354  _readResponse = function*(dom) {
355    if (!dom || dom.documentElement.localName != "multistatus") {
356      throw Components.Exception(
357        `Expected a multistatus response, but didn't get one`,
358        Cr.NS_ERROR_FAILURE
359      );
360    }
361
362    for (let r of dom.querySelectorAll("response")) {
363      let response = {
364        href: r.querySelector("href")?.textContent,
365      };
366
367      let responseStatus = r.querySelector("response > status");
368      if (responseStatus?.textContent.startsWith("HTTP/1.1 404")) {
369        response.notFound = true;
370        yield response;
371        continue;
372      }
373
374      for (let p of r.querySelectorAll("response > propstat")) {
375        let status = p.querySelector("propstat > status").textContent;
376        if (status == "HTTP/1.1 200 OK") {
377          response.properties = p.querySelector("propstat > prop");
378        }
379      }
380
381      yield response;
382    }
383  };
384
385  /**
386   * Converts the card to a vCard and performs a PUT request to store it on the
387   * server. Then immediately performs a GET request ensuring the local copy
388   * matches the server copy. Stores the card in the database on success.
389   *
390   * @param {nsIAbCard} card
391   * @returns {boolean} true if the PUT request succeeded without conflict,
392   *     false if there was a conflict.
393   * @throws if the server responded with anything other than a success or
394   *     conflict status code.
395   */
396  async _sendCardToServer(card) {
397    let href = this._getCardHref(card);
398    let requestDetails = {
399      method: "PUT",
400      contentType: "text/vcard",
401    };
402
403    let existingVCard = card.getProperty("_vCard", "");
404    if (existingVCard) {
405      requestDetails.body = VCardUtils.modifyVCard(existingVCard, card);
406      let existingETag = card.getProperty("_etag", "");
407      if (existingETag) {
408        requestDetails.headers = { "If-Match": existingETag };
409      }
410    } else {
411      // TODO 3.0 is the default, should we be able to use other versions?
412      requestDetails.body = VCardUtils.abCardToVCard(card, "3.0");
413    }
414
415    let response;
416    try {
417      log.debug(`Sending ${href} to server.`);
418      response = await this._makeRequest(href, requestDetails);
419    } catch (ex) {
420      Services.obs.notifyObservers(this, "addrbook-directory-sync-failed");
421      this._uidsToSync.add(card.UID);
422      throw ex;
423    }
424
425    let conflictResponse = [409, 412].includes(response.status);
426    if (response.status >= 400 && !conflictResponse) {
427      throw Components.Exception(
428        `Sending card to the server failed, response was ${response.status} ${response.statusText}`,
429        Cr.NS_ERROR_FAILURE
430      );
431    }
432
433    // At this point we *should* be able to make a simple GET request and
434    // store the response. But Google moves the data (fair enough) without
435    // telling us where it went (c'mon, really?). Fortunately a multiget
436    // request at the original location works.
437
438    response = await this._multigetRequest([href]);
439
440    for (let { href, properties } of this._readResponse(response.dom)) {
441      if (!properties) {
442        continue;
443      }
444
445      let etag = properties.querySelector("getetag")?.textContent;
446      let vCard = normalizeLineEndings(
447        properties.querySelector("address-data")?.textContent
448      );
449
450      if (conflictResponse) {
451        card.setProperty("_etag", etag);
452        card.setProperty("_href", href);
453        card.setProperty("_vCard", vCard);
454        return false;
455      }
456
457      let abCard = VCardUtils.vCardToAbCard(vCard);
458      abCard.setProperty("_etag", etag);
459      abCard.setProperty("_href", href);
460      abCard.setProperty("_vCard", vCard);
461
462      if (abCard.UID == card.UID) {
463        super.modifyCard(abCard);
464      } else {
465        super.dropCard(abCard, false);
466        super.deleteCards([card]);
467      }
468    }
469
470    return !conflictResponse;
471  }
472
473  /**
474   * Deletes card from the server.
475   *
476   * @param {nsIAbCard} card
477   */
478  async _deleteCardFromServer(cardOrHRef) {
479    let href;
480    if (typeof cardOrHRef == "string") {
481      href = cardOrHRef;
482    } else {
483      href = cardOrHRef.getProperty("_href", "");
484    }
485    if (!href) {
486      return;
487    }
488
489    try {
490      log.debug(`Removing ${href} from server.`);
491      await this._makeRequest(href, { method: "DELETE" });
492    } catch (ex) {
493      Services.obs.notifyObservers(this, "addrbook-directory-sync-failed");
494      this._hrefsToRemove.add(href);
495      throw ex;
496    }
497  }
498
499  /**
500   * Set up a repeating timer for synchronisation with the server. The timer's
501   * interval is defined by pref, set it to 0 to disable sync'ing altogether.
502   */
503  _scheduleNextSync() {
504    if (this._syncTimer) {
505      clearInterval(this._syncTimer);
506      this._syncTimer = null;
507    }
508
509    let interval = this.getIntValue("carddav.syncinterval", 30);
510    if (interval <= 0) {
511      return;
512    }
513
514    this._syncTimer = setInterval(
515      () => this.syncWithServer(false),
516      interval * 60000
517    );
518  }
519
520  /**
521   * Get all cards on the server and add them to this directory. This should
522   * be used for the initial population of a directory.
523   */
524  async fetchAllFromServer() {
525    this._syncInProgress = true;
526
527    let data = `<propfind xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
528      <prop>
529        <resourcetype/>
530        <getetag/>
531        <cs:getctag/>
532      </prop>
533    </propfind>`;
534
535    let response = await this._makeRequest("", {
536      method: "PROPFIND",
537      body: data,
538      headers: {
539        Depth: 1,
540      },
541      expectedStatuses: [207],
542    });
543
544    let hrefsToFetch = [];
545    for (let { href, properties } of this._readResponse(response.dom)) {
546      if (properties && !properties.querySelector("resourcetype collection")) {
547        hrefsToFetch.push(href);
548      }
549    }
550
551    if (hrefsToFetch.length > 0) {
552      response = await this._multigetRequest(hrefsToFetch);
553
554      let abCards = [];
555
556      for (let { href, properties } of this._readResponse(response.dom)) {
557        if (!properties) {
558          continue;
559        }
560
561        let etag = properties.querySelector("getetag")?.textContent;
562        let vCard = normalizeLineEndings(
563          properties.querySelector("address-data")?.textContent
564        );
565
566        try {
567          let abCard = VCardUtils.vCardToAbCard(vCard);
568          abCard.setProperty("_etag", etag);
569          abCard.setProperty("_href", href);
570          abCard.setProperty("_vCard", vCard);
571          abCards.push(abCard);
572        } catch (ex) {
573          log.error(`Error parsing: ${vCard}`);
574          Cu.reportError(ex);
575        }
576      }
577
578      await this._bulkAddCards(abCards);
579    }
580
581    await this._getSyncToken();
582
583    Services.obs.notifyObservers(this, "addrbook-directory-synced");
584
585    this._scheduleNextSync();
586    this._syncInProgress = false;
587  }
588
589  /**
590   * Begin a sync operation. This function will decide which sync protocol to
591   * use based on the directory's configuration. It will also (re)start the
592   * timer for the next synchronisation unless told not to.
593   *
594   * @param {boolean} shouldResetTimer
595   */
596  async syncWithServer(shouldResetTimer = true) {
597    if (this._syncInProgress || !this._serverURL) {
598      return;
599    }
600
601    log.log("Performing sync with server.");
602    this._syncInProgress = true;
603
604    try {
605      // First perform all pending removals. We don't want to have deleted cards
606      // reappearing when we sync.
607      for (let href of this._hrefsToRemove) {
608        await this._deleteCardFromServer(href);
609      }
610      this._hrefsToRemove.clear();
611
612      // Now update any cards that were modified while not connected to the server.
613      for (let uid of this._uidsToSync) {
614        let card = this.getCard(uid);
615        // The card may no longer exist. It shouldn't still be listed to send,
616        // but it might be.
617        if (card) {
618          await this._sendCardToServer(card);
619        }
620      }
621      this._uidsToSync.clear();
622
623      if (this._syncToken) {
624        await this.updateAllFromServerV2();
625      } else {
626        await this.updateAllFromServerV1();
627      }
628    } catch (ex) {
629      log.error("Sync with server failed.");
630      throw ex;
631    } finally {
632      if (shouldResetTimer) {
633        this._scheduleNextSync();
634      }
635      this._syncInProgress = false;
636    }
637  }
638
639  /**
640   * Compares cards in the directory with cards on the server, and updates the
641   * directory to match what is on the server.
642   */
643  async updateAllFromServerV1() {
644    let data = `<propfind xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
645      <prop>
646        <resourcetype/>
647        <getetag/>
648        <cs:getctag/>
649      </prop>
650    </propfind>`;
651
652    let response = await this._makeRequest("", {
653      method: "PROPFIND",
654      body: data,
655      headers: {
656        Depth: 1,
657      },
658      expectedStatuses: [207],
659    });
660
661    let hrefMap = new Map();
662    for (let { href, properties } of this._readResponse(response.dom)) {
663      if (
664        !properties ||
665        !properties.querySelector("resourcetype") ||
666        properties.querySelector("resourcetype collection")
667      ) {
668        continue;
669      }
670
671      let etag = properties.querySelector("getetag").textContent;
672      hrefMap.set(href, etag);
673    }
674
675    let cardMap = new Map();
676    let hrefsToFetch = [];
677    let cardsToDelete = [];
678    for (let card of this.childCards) {
679      let href = card.getProperty("_href", "");
680      let etag = card.getProperty("_etag", "");
681
682      if (!href || !etag) {
683        // Not sure how we got here. Ignore it.
684        continue;
685      }
686      cardMap.set(href, card);
687      if (hrefMap.has(href)) {
688        if (hrefMap.get(href) != etag) {
689          // The card was updated on server.
690          hrefsToFetch.push(href);
691        }
692      } else {
693        // The card doesn't exist on the server.
694        cardsToDelete.push(card);
695      }
696    }
697
698    for (let href of hrefMap.keys()) {
699      if (!cardMap.has(href)) {
700        // The card is new on the server.
701        hrefsToFetch.push(href);
702      }
703    }
704
705    // If this directory is set to read-only, the following operations would
706    // throw NS_ERROR_FAILURE, but sync operations are allowed on a read-only
707    // directory, so set this._overrideReadOnly to avoid the exception.
708    //
709    // Do not use await while it is set, and use a try/finally block to ensure
710    // it is cleared.
711
712    if (cardsToDelete.length > 0) {
713      this._overrideReadOnly = true;
714      try {
715        super.deleteCards(cardsToDelete);
716      } finally {
717        this._overrideReadOnly = false;
718      }
719    }
720
721    await this._fetchAndStore(hrefsToFetch);
722
723    log.log("Sync with server completed successfully.");
724    Services.obs.notifyObservers(this, "addrbook-directory-synced");
725  }
726
727  /**
728   * Retrieves the current sync token from the server.
729   *
730   * @see RFC 6578
731   */
732  async _getSyncToken() {
733    let data = `<propfind xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}>
734      <prop>
735         <displayname/>
736         <cs:getctag/>
737         <sync-token/>
738      </prop>
739    </propfind>`;
740
741    let response = await this._makeRequest("", {
742      method: "PROPFIND",
743      body: data,
744      headers: {
745        Depth: 0,
746      },
747    });
748
749    if (response.status == 207) {
750      for (let { properties } of this._readResponse(response.dom)) {
751        let token = properties?.querySelector("prop sync-token");
752        if (token) {
753          this._syncToken = token.textContent;
754          return;
755        }
756      }
757    }
758
759    this._syncToken = "";
760  }
761
762  /**
763   * Gets a list of changes on the server since the last call to getSyncToken
764   * or updateAllFromServerV2, and updates the directory to match what is on
765   * the server.
766   *
767   * @see RFC 6578
768   */
769  async updateAllFromServerV2() {
770    let syncToken = this._syncToken;
771    if (!syncToken) {
772      throw new Components.Exception("No sync token", Cr.NS_ERROR_UNEXPECTED);
773    }
774
775    let data = `<sync-collection xmlns="${
776      PREFIX_BINDINGS.d
777    }" ${NAMESPACE_STRING}>
778      <sync-token>${xmlEncode(syncToken)}</sync-token>
779      <sync-level>1</sync-level>
780      <prop>
781        <getetag/>
782        <card:address-data/>
783      </prop>
784    </sync-collection>`;
785
786    let response = await this._makeRequest("", {
787      method: "REPORT",
788      body: data,
789      headers: {
790        Depth: 1, // Only Google seems to need this.
791      },
792      expectedStatuses: [207],
793    });
794    let dom = response.dom;
795
796    // If this directory is set to read-only, the following operations would
797    // throw NS_ERROR_FAILURE, but sync operations are allowed on a read-only
798    // directory, so set this._overrideReadOnly to avoid the exception.
799    //
800    // Do not use await while it is set, and use a try/finally block to ensure
801    // it is cleared.
802
803    let hrefsToFetch = [];
804    try {
805      this._overrideReadOnly = true;
806      let cardsToDelete = [];
807      for (let { href, notFound, properties } of this._readResponse(dom)) {
808        let card = this.getCardFromProperty("_href", href, true);
809        if (notFound) {
810          if (card) {
811            cardsToDelete.push(card);
812          }
813          continue;
814        }
815        if (!properties) {
816          continue;
817        }
818
819        let etag = properties.querySelector("getetag")?.textContent;
820        if (!etag) {
821          continue;
822        }
823        let vCard = properties.querySelector("address-data")?.textContent;
824        if (!vCard) {
825          hrefsToFetch.push(href);
826          continue;
827        }
828        vCard = normalizeLineEndings(vCard);
829
830        let abCard = VCardUtils.vCardToAbCard(vCard);
831        abCard.setProperty("_etag", etag);
832        abCard.setProperty("_href", href);
833        abCard.setProperty("_vCard", vCard);
834
835        if (card) {
836          if (card.getProperty("_etag", "") != etag) {
837            super.modifyCard(abCard);
838          }
839        } else {
840          super.dropCard(abCard, false);
841        }
842      }
843
844      if (cardsToDelete.length > 0) {
845        super.deleteCards(cardsToDelete);
846      }
847    } finally {
848      this._overrideReadOnly = false;
849    }
850
851    await this._fetchAndStore(hrefsToFetch);
852
853    this._syncToken = dom.querySelector("sync-token").textContent;
854
855    log.log("Sync with server completed successfully.");
856    Services.obs.notifyObservers(this, "addrbook-directory-synced");
857  }
858
859  static forFile(fileName) {
860    let directory = super.forFile(fileName);
861    if (directory instanceof CardDAVDirectory) {
862      return directory;
863    }
864    return undefined;
865  }
866}
867CardDAVDirectory.prototype.classID = Components.ID(
868  "{1fa9941a-07d5-4a6f-9673-15327fc2b9ab}"
869);
870
871/**
872 * Ensure that `string` always has Windows line-endings. Some functions,
873 * notably DOMParser.parseFromString, strip \r, but we want it because \r\n
874 * is a part of the vCard specification.
875 */
876function normalizeLineEndings(string) {
877  if (string.includes("\r\n")) {
878    return string;
879  }
880  return string.replace(/\n/g, "\r\n");
881}
882
883/**
884 * Encode special characters safely for XML.
885 */
886function xmlEncode(string) {
887  return string
888    .replace(/&/g, "&amp;")
889    .replace(/"/g, "&quot;")
890    .replace(/</g, "&lt;")
891    .replace(/>/g, "&gt;");
892}
893