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, "&") 889 .replace(/"/g, """) 890 .replace(/</g, "<") 891 .replace(/>/g, ">"); 892} 893