diff options
Diffstat (limited to 'comm/mailnews/addrbook/modules')
24 files changed, 8521 insertions, 0 deletions
diff --git a/comm/mailnews/addrbook/modules/AddrBookCard.jsm b/comm/mailnews/addrbook/modules/AddrBookCard.jsm new file mode 100644 index 0000000000..23f387f921 --- /dev/null +++ b/comm/mailnews/addrbook/modules/AddrBookCard.jsm @@ -0,0 +1,481 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["AddrBookCard"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BANISHED_PROPERTIES: "resource:///modules/VCardUtils.jsm", + newUID: "resource:///modules/AddrBookUtils.jsm", + VCardProperties: "resource:///modules/VCardUtils.jsm", + VCardPropertyEntry: "resource:///modules/VCardUtils.jsm", +}); + +/** + * Prototype for nsIAbCard objects that are not mailing lists. + * + * @implements {nsIAbCard} + */ +function AddrBookCard() { + this._directoryUID = ""; + this._properties = new Map([ + ["PopularityIndex", 0], + ["LastModifiedDate", 0], + ]); + + this._hasVCard = false; + XPCOMUtils.defineLazyGetter(this, "_vCardProperties", () => { + // Lazy creation of the VCardProperties object. Change the `_properties` + // object as much as you like (e.g. loading in properties from a database) + // before running this code. After it runs, the `_vCardProperties` object + // takes over and anything in `_properties` which could be stored in the + // vCard will be ignored! + + this._hasVCard = true; + + let vCard = this.getProperty("_vCard", ""); + try { + if (vCard) { + let vCardProperties = lazy.VCardProperties.fromVCard(vCard, { + isGoogleCardDAV: this._isGoogleCardDAV, + }); + // Custom1..4 properties could still exist as nsIAbCard properties. + // Migrate them now. + for (let key of ["Custom1", "Custom2", "Custom3", "Custom4"]) { + let value = this.getProperty(key, ""); + if ( + value && + vCardProperties.getFirstEntry(`x-${key.toLowerCase()}`) === null + ) { + vCardProperties.addEntry( + new lazy.VCardPropertyEntry( + `x-${key.toLowerCase()}`, + {}, + "text", + value + ) + ); + } + this.deleteProperty(key); + } + return vCardProperties; + } + return lazy.VCardProperties.fromPropertyMap(this._properties); + } catch (error) { + console.error("Error creating vCard properties", error); + // Return an empty VCardProperties object if parsing failed + // catastrophically. + return new lazy.VCardProperties("4.0"); + } + }); +} + +AddrBookCard.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIAbCard"]), + classID: Components.ID("{1143991d-31cd-4ea6-9c97-c587d990d724}"), + + /* nsIAbCard */ + + generateName(generateFormat, bundle) { + let result = ""; + switch (generateFormat) { + case Ci.nsIAbCard.GENERATE_DISPLAY_NAME: + result = this.displayName; + break; + + case Ci.nsIAbCard.GENERATE_LAST_FIRST_ORDER: + if (this.lastName) { + let otherNames = [ + this.prefixName, + this.firstName, + this.middleName, + this.suffixName, + ] + .filter(Boolean) + .join(" "); + if (!otherNames) { + // Only use the lastName if we don't have anything to add after the + // comma, in order to avoid for the string to finish with ", ". + result = this.lastName; + } else { + result = + bundle?.formatStringFromName("lastFirstFormat", [ + this.lastName, + otherNames, + ]) ?? `${this.lastName}, ${otherNames}`; + } + } + break; + + default: + let startNames = [this.prefixName, this.firstName, this.middleName] + .filter(Boolean) + .join(" "); + let endNames = [this.lastName, this.suffixName] + .filter(Boolean) + .join(" "); + result = + bundle?.formatStringFromName("firstLastFormat", [ + startNames, + endNames, + ]) ?? `${startNames} ${endNames}`; + break; + } + + // Remove any leftover blank spaces. + result = result.trim(); + + if (result == "" || result == ",") { + result = + this.displayName || + [ + this.prefixName, + this.firstName, + this.middleName, + this.lastName, + this.suffixName, + ] + .filter(Boolean) + .join(" ") + .trim(); + + if (!result) { + // So far we don't have anything to show as a contact name. + + if (this.primaryEmail) { + // Let's use the primary email localpart. + result = this.primaryEmail.split("@", 1)[0]; + } else { + // We don't have a primary email either, let's try with the + // organization name. + result = !this._hasVCard + ? this.getProperty("Company", "") + : this._vCardProperties.getFirstValue("org"); + } + } + } + return result || ""; + }, + get directoryUID() { + return this._directoryUID; + }, + set directoryUID(value) { + this._directoryUID = value; + }, + get UID() { + if (!this._uid) { + this._uid = lazy.newUID(); + } + return this._uid; + }, + set UID(value) { + if (this._uid && value != this._uid) { + throw Components.Exception( + `Bad UID: got ${value} != ${this.uid}`, + Cr.NS_ERROR_UNEXPECTED + ); + } + this._uid = value; + }, + get properties() { + let props = []; + for (const [name, value] of this._properties) { + props.push({ + get name() { + return name; + }, + get value() { + return value; + }, + QueryInterface: ChromeUtils.generateQI(["nsIProperty"]), + }); + } + return props; + }, + get supportsVCard() { + return true; + }, + get vCardProperties() { + return this._vCardProperties; + }, + get firstName() { + if (!this._hasVCard) { + return this.getProperty("FirstName", ""); + } + let name = this._vCardProperties.getFirstValue("n"); + if (!Array.isArray(name)) { + return ""; + } + name = name[1]; + if (Array.isArray(name)) { + name = name.join(" "); + } + return name; + }, + set firstName(value) { + let n = this._vCardProperties.getFirstEntry("n"); + if (n) { + n.value[1] = value; + } else { + this._vCardProperties.addEntry( + new lazy.VCardPropertyEntry("n", {}, "text", ["", value, "", "", ""]) + ); + } + }, + get lastName() { + if (!this._hasVCard) { + return this.getProperty("LastName", ""); + } + let name = this._vCardProperties.getFirstValue("n"); + if (!Array.isArray(name)) { + return ""; + } + name = name[0]; + if (Array.isArray(name)) { + name = name.join(" "); + } + return name; + }, + set lastName(value) { + let n = this._vCardProperties.getFirstEntry("n"); + if (n) { + n.value[0] = value; + } else { + this._vCardProperties.addEntry( + new lazy.VCardPropertyEntry("n", {}, "text", [value, "", "", "", ""]) + ); + } + }, + get displayName() { + if (!this._hasVCard) { + return this.getProperty("DisplayName", ""); + } + return this._vCardProperties.getFirstValue("fn") || ""; + }, + set displayName(value) { + let fn = this._vCardProperties.getFirstEntry("fn"); + if (fn) { + fn.value = value; + } else { + this._vCardProperties.addEntry( + new lazy.VCardPropertyEntry("fn", {}, "text", value) + ); + } + }, + get primaryEmail() { + if (!this._hasVCard) { + return this.getProperty("PrimaryEmail", ""); + } + return this._vCardProperties.getAllValuesSorted("email")[0] ?? ""; + }, + set primaryEmail(value) { + let entries = this._vCardProperties.getAllEntriesSorted("email"); + if (entries.length && entries[0].value != value) { + this._vCardProperties.removeEntry(entries[0]); + entries.shift(); + } + + if (value) { + let existing = entries.find(e => e.value == value); + if (existing) { + existing.params.pref = "1"; + } else { + this._vCardProperties.addEntry( + new lazy.VCardPropertyEntry("email", { pref: "1" }, "text", value) + ); + } + } else if (entries.length) { + entries[0].params.pref = "1"; + } + }, + get isMailList() { + return false; + }, + get mailListURI() { + return ""; + }, + get emailAddresses() { + return this._vCardProperties.getAllValuesSorted("email"); + }, + get photoURL() { + let photoEntry = this.vCardProperties.getFirstEntry("photo"); + if (photoEntry?.value) { + if (photoEntry.value?.startsWith("data:image/")) { + // This is a version 4.0 card + // OR a version 3.0 card with the URI type set (uncommon) + // OR a version 3.0 card that is lying about its type. + return photoEntry.value; + } + if (photoEntry.type == "binary" && photoEntry.value.startsWith("iVBO")) { + // This is a version 3.0 card. + // The first 3 bytes say this image is PNG. + return `data:image/png;base64,${photoEntry.value}`; + } + if (photoEntry.type == "binary" && photoEntry.value.startsWith("/9j/")) { + // This is a version 3.0 card. + // The first 3 bytes say this image is JPEG. + return `data:image/jpeg;base64,${photoEntry.value}`; + } + if (photoEntry.type == "uri" && /^https?:\/\//.test(photoEntry.value)) { + // A remote URI. + return photoEntry.value; + } + } + + let photoName = this.getProperty("PhotoName", ""); + if (photoName) { + let file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("Photos"); + file.append(photoName); + return Services.io.newFileURI(file).spec; + } + + return ""; + }, + + getProperty(name, defaultValue) { + if (this._properties.has(name)) { + return this._properties.get(name); + } + return defaultValue; + }, + getPropertyAsAString(name) { + if (!this._properties.has(name)) { + return ""; + } + return this.getProperty(name); + }, + getPropertyAsAUTF8String(name) { + if (!this._properties.has(name)) { + throw Components.Exception(`${name} N/A`, Cr.NS_ERROR_NOT_AVAILABLE); + } + return this.getProperty(name); + }, + getPropertyAsUint32(name) { + let value = this.getProperty(name); + if (!isNaN(parseInt(value, 10))) { + return parseInt(value, 10); + } + if (!isNaN(parseInt(value, 16))) { + return parseInt(value, 16); + } + throw Components.Exception( + `${name}: ${value} - not an int`, + Cr.NS_ERROR_NOT_AVAILABLE + ); + }, + getPropertyAsBool(name, defaultValue) { + let value = this.getProperty(name); + switch (value) { + case false: + case 0: + case "0": + return false; + case true: + case 1: + case "1": + return true; + case undefined: + return defaultValue; + } + throw Components.Exception( + `${name}: ${value} - not a boolean`, + Cr.NS_ERROR_NOT_AVAILABLE + ); + }, + setProperty(name, value) { + if (lazy.BANISHED_PROPERTIES.includes(name)) { + throw new Components.Exception( + `Unable to set ${name} as a property, use vCardProperties`, + Cr.NS_ERROR_UNEXPECTED + ); + } + if ([null, undefined, ""].includes(value)) { + this._properties.delete(name); + return; + } + if (typeof value == "boolean") { + value = value ? "1" : "0"; + } + this._properties.set(name, "" + value); + }, + setPropertyAsAString(name, value) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + }, + setPropertyAsAUTF8String(name, value) { + this.setProperty(name, value); + }, + setPropertyAsUint32(name, value) { + this.setProperty(name, value); + }, + setPropertyAsBool(name, value) { + this.setProperty(name, value ? "1" : "0"); + }, + deleteProperty(name) { + this._properties.delete(name); + }, + hasEmailAddress(emailAddress) { + emailAddress = emailAddress.toLowerCase(); + return this.emailAddresses.some(e => e.toLowerCase() == emailAddress); + }, + translateTo(type) { + if (type == "vcard") { + if (!this._vCardProperties.getFirstValue("uid")) { + this._vCardProperties.addValue("uid", this.UID); + } + return encodeURIComponent(this._vCardProperties.toVCard()); + } + // Get nsAbCardProperty to do the work, the code is in C++ anyway. + let cardCopy = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance( + Ci.nsIAbCard + ); + cardCopy.UID = this.UID; + cardCopy.copy(this); + return cardCopy.translateTo(type); + }, + generatePhoneticName(lastNameFirst) { + if (lastNameFirst) { + return ( + this.getProperty("PhoneticLastName", "") + + this.getProperty("PhoneticFirstName", "") + ); + } + return ( + this.getProperty("PhoneticFirstName", "") + + this.getProperty("PhoneticLastName", "") + ); + }, + generateChatName() { + for (let name of [ + "_GoogleTalk", + "_AimScreenName", + "_Yahoo", + "_Skype", + "_QQ", + "_MSN", + "_ICQ", + "_JabberId", + "_IRC", + ]) { + if (this._properties.has(name)) { + return this._properties.get(name); + } + } + return ""; + }, + copy(srcCard) { + throw Components.Exception( + "nsIAbCard.copy() not implemented", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + }, + equals(card) { + return this.UID == card.UID; + }, +}; diff --git a/comm/mailnews/addrbook/modules/AddrBookDirectory.jsm b/comm/mailnews/addrbook/modules/AddrBookDirectory.jsm new file mode 100644 index 0000000000..b35f35b147 --- /dev/null +++ b/comm/mailnews/addrbook/modules/AddrBookDirectory.jsm @@ -0,0 +1,817 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["AddrBookDirectory"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const lazy = {}; +XPCOMUtils.defineLazyModuleGetters(lazy, { + AddrBookCard: "resource:///modules/AddrBookCard.jsm", + AddrBookMailingList: "resource:///modules/AddrBookMailingList.jsm", + BANISHED_PROPERTIES: "resource:///modules/VCardUtils.jsm", + compareAddressBooks: "resource:///modules/AddrBookUtils.jsm", + newUID: "resource:///modules/AddrBookUtils.jsm", + VCardProperties: "resource:///modules/VCardUtils.jsm", +}); + +/** + * Abstract base class implementing nsIAbDirectory. + * + * @abstract + * @implements {nsIAbDirectory} + */ +class AddrBookDirectory { + QueryInterface = ChromeUtils.generateQI(["nsIAbDirectory"]); + + constructor() { + this._uid = null; + this._dirName = null; + } + + _initialized = false; + init(uri) { + if (this._initialized) { + throw new Components.Exception( + `Directory already initialized: ${uri}`, + Cr.NS_ERROR_ALREADY_INITIALIZED + ); + } + + // If this._readOnly is true, the user is prevented from making changes to + // the contacts. Subclasses may override this (for example to sync with a + // server) by setting this._overrideReadOnly to true, but must clear it + // before yielding to another thread (e.g. awaiting a Promise). + + if (this._dirPrefId) { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_readOnly", + `${this.dirPrefId}.readOnly`, + false + ); + } + + this._initialized = true; + } + async cleanUp() { + if (!this._initialized) { + throw new Components.Exception( + "Directory not initialized", + Cr.NS_ERROR_NOT_INITIALIZED + ); + } + } + + get _prefBranch() { + if (this.__prefBranch) { + return this.__prefBranch; + } + if (!this._dirPrefId) { + throw Components.Exception("No dirPrefId!", Cr.NS_ERROR_NOT_AVAILABLE); + } + return (this.__prefBranch = Services.prefs.getBranch( + `${this._dirPrefId}.` + )); + } + /** @abstract */ + get lists() { + throw new Components.Exception( + `${this.constructor.name} does not implement lists getter.`, + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + /** @abstract */ + get cards() { + throw new Components.Exception( + `${this.constructor.name} does not implement cards getter.`, + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + getCard(uid) { + let card = new lazy.AddrBookCard(); + card.directoryUID = this.UID; + card._uid = uid; + card._properties = this.loadCardProperties(uid); + card._isGoogleCardDAV = this._isGoogleCardDAV; + return card.QueryInterface(Ci.nsIAbCard); + } + /** @abstract */ + loadCardProperties(uid) { + throw new Components.Exception( + `${this.constructor.name} does not implement loadCardProperties.`, + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + /** @abstract */ + saveCardProperties(uid, properties) { + throw new Components.Exception( + `${this.constructor.name} does not implement saveCardProperties.`, + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + /** @abstract */ + deleteCard(uid) { + throw new Components.Exception( + `${this.constructor.name} does not implement deleteCard.`, + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + /** @abstract */ + saveList(list) { + throw new Components.Exception( + `${this.constructor.name} does not implement saveList.`, + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + /** @abstract */ + deleteList(uid) { + throw new Components.Exception( + `${this.constructor.name} does not implement deleteList.`, + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + /** + * Create a Map of the properties to record when saving `card`, including + * any changes we want to make just before saving. + * + * @param {nsIAbCard} card + * @param {?string} uid + * @returns {Map<string, string>} + */ + prepareToSaveCard(card, uid) { + let propertyMap = new Map( + Array.from(card.properties, p => [p.name, p.value]) + ); + let newProperties = new Map(); + + // Get a VCardProperties object for the card. + let vCardProperties; + if (card.supportsVCard) { + vCardProperties = card.vCardProperties; + } else { + vCardProperties = lazy.VCardProperties.fromPropertyMap(propertyMap); + } + + if (uid) { + // Force the UID to be as passed. + vCardProperties.clearValues("uid"); + vCardProperties.addValue("uid", uid); + } else if (vCardProperties.getFirstValue("uid") != card.UID) { + vCardProperties.clearValues("uid"); + vCardProperties.addValue("uid", card.UID); + } + + // Collect only the properties we intend to keep. + for (let [name, value] of propertyMap) { + if (lazy.BANISHED_PROPERTIES.includes(name)) { + continue; + } + if (value !== null && value !== undefined && value !== "") { + newProperties.set(name, value); + } + } + + // Add the vCard and the properties from it we want to cache. + newProperties.set("_vCard", vCardProperties.toVCard()); + + let displayName = vCardProperties.getFirstValue("fn"); + newProperties.set("DisplayName", displayName || ""); + + let flatten = value => { + if (Array.isArray(value)) { + return value.join(" "); + } + return value; + }; + + let name = vCardProperties.getFirstValue("n"); + if (Array.isArray(name)) { + newProperties.set("FirstName", flatten(name[1])); + newProperties.set("LastName", flatten(name[0])); + } + + let email = vCardProperties.getAllValuesSorted("email"); + if (email[0]) { + newProperties.set("PrimaryEmail", email[0]); + } + if (email[1]) { + newProperties.set("SecondEmail", email[1]); + } + + let nickname = vCardProperties.getFirstValue("nickname"); + if (nickname) { + newProperties.set("NickName", flatten(nickname)); + } + + // Always set the last modified date. + newProperties.set("LastModifiedDate", "" + Math.floor(Date.now() / 1000)); + return newProperties; + } + + /* nsIAbDirectory */ + + get readOnly() { + return this._readOnly; + } + get isRemote() { + return false; + } + get isSecure() { + return false; + } + get propertiesChromeURI() { + return "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml"; + } + get dirPrefId() { + return this._dirPrefId; + } + get dirName() { + if (this._dirName === null) { + this._dirName = this.getLocalizedStringValue("description", ""); + } + return this._dirName; + } + set dirName(value) { + this.setLocalizedStringValue("description", value); + this._dirName = value; + Services.obs.notifyObservers(this, "addrbook-directory-updated", "DirName"); + } + get dirType() { + return Ci.nsIAbManager.JS_DIRECTORY_TYPE; + } + get fileName() { + return this._fileName; + } + get UID() { + if (!this._uid) { + if (this._prefBranch.getPrefType("uid") == Services.prefs.PREF_STRING) { + this._uid = this._prefBranch.getStringPref("uid"); + } else { + this._uid = lazy.newUID(); + this._prefBranch.setStringPref("uid", this._uid); + } + } + return this._uid; + } + get URI() { + return this._uri; + } + get position() { + return this._prefBranch.getIntPref("position", 1); + } + get childNodes() { + let lists = Array.from( + this.lists.values(), + list => + new lazy.AddrBookMailingList( + list.uid, + this, + list.name, + list.nickName, + list.description + ).asDirectory + ); + lists.sort(lazy.compareAddressBooks); + return lists; + } + /** @abstract */ + get childCardCount() { + throw new Components.Exception( + `${this.constructor.name} does not implement childCardCount getter.`, + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + get childCards() { + let results = Array.from( + this.lists.values(), + list => + new lazy.AddrBookMailingList( + list.uid, + this, + list.name, + list.nickName, + list.description + ).asCard + ).concat(Array.from(this.cards.keys(), this.getCard, this)); + + return results; + } + get supportsMailingLists() { + return true; + } + + search(query, string, listener) { + if (!listener) { + return; + } + if (!query) { + listener.onSearchFinished(Cr.NS_ERROR_FAILURE, true, null, ""); + return; + } + if (query[0] == "?") { + query = query.substring(1); + } + + let results = Array.from( + this.lists.values(), + list => + new lazy.AddrBookMailingList( + list.uid, + this, + list.name, + list.nickName, + list.description + ).asCard + ).concat(Array.from(this.cards.keys(), this.getCard, this)); + + // Process the query string into a tree of conditions to match. + let lispRegexp = /^\((and|or|not|([^\)]*)(\)+))/; + let index = 0; + let rootQuery = { children: [], op: "or" }; + let currentQuery = rootQuery; + + while (true) { + let match = lispRegexp.exec(query.substring(index)); + if (!match) { + break; + } + index += match[0].length; + + if (["and", "or", "not"].includes(match[1])) { + // For the opening bracket, step down a level. + let child = { + parent: currentQuery, + children: [], + op: match[1], + }; + currentQuery.children.push(child); + currentQuery = child; + } else { + let [name, condition, value] = match[2].split(","); + currentQuery.children.push({ + name, + condition, + value: decodeURIComponent(value).toLowerCase(), + }); + + // For each closing bracket except the first, step up a level. + for (let i = match[3].length - 1; i > 0; i--) { + currentQuery = currentQuery.parent; + } + } + } + + results = results.filter(card => { + let properties; + if (card.isMailList) { + properties = new Map([ + ["DisplayName", card.displayName], + ["NickName", card.getProperty("NickName", "")], + ["Notes", card.getProperty("Notes", "")], + ]); + } else if (card._properties.has("_vCard")) { + try { + properties = card.vCardProperties.toPropertyMap(); + } catch (ex) { + // Parsing failed. Skip the vCard and just use the other properties. + console.error(ex); + properties = new Map(); + } + for (let [key, value] of card._properties) { + if (!properties.has(key)) { + properties.set(key, value); + } + } + } else { + properties = card._properties; + } + let matches = b => { + if ("condition" in b) { + let { name, condition, value } = b; + if (name == "IsMailList" && condition == "=") { + return card.isMailList == (value == "true"); + } + let cardValue = properties.get(name); + if (!cardValue) { + return condition == "!ex"; + } + if (condition == "ex") { + return true; + } + + cardValue = cardValue.toLowerCase(); + switch (condition) { + case "=": + return cardValue == value; + case "!=": + return cardValue != value; + case "lt": + return cardValue < value; + case "gt": + return cardValue > value; + case "bw": + return cardValue.startsWith(value); + case "ew": + return cardValue.endsWith(value); + case "c": + return cardValue.includes(value); + case "!c": + return !cardValue.includes(value); + case "~=": + case "regex": + default: + return false; + } + } + if (b.op == "or") { + return b.children.some(bb => matches(bb)); + } + if (b.op == "and") { + return b.children.every(bb => matches(bb)); + } + if (b.op == "not") { + return !matches(b.children[0]); + } + return false; + }; + + return matches(rootQuery); + }, this); + + for (let card of results) { + listener.onSearchFoundCard(card); + } + listener.onSearchFinished(Cr.NS_OK, true, null, ""); + } + generateName(generateFormat, bundle) { + return this.dirName; + } + cardForEmailAddress(emailAddress) { + if (!emailAddress) { + return null; + } + + // Check the properties. We copy the first two addresses to properties for + // this purpose, so it should be fast. + let card = this.getCardFromProperty("PrimaryEmail", emailAddress, false); + if (card) { + return card; + } + card = this.getCardFromProperty("SecondEmail", emailAddress, false); + if (card) { + return card; + } + + // Nothing so far? Go through all the cards checking all of the addresses. + // This could be slow. + emailAddress = emailAddress.toLowerCase(); + for (let [uid, properties] of this.cards) { + let vCard = properties.get("_vCard"); + // If the vCard string doesn't include the email address, the parsed + // vCard won't include it either, so don't waste time parsing it. + if (!vCard?.toLowerCase().includes(emailAddress)) { + continue; + } + card = this.getCard(uid); + if (card.emailAddresses.some(e => e.toLowerCase() == emailAddress)) { + return card; + } + } + + return null; + } + /** @abstract */ + getCardFromProperty(property, value, caseSensitive) { + throw new Components.Exception( + `${this.constructor.name} does not implement getCardFromProperty.`, + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + /** @abstract */ + getCardsFromProperty(property, value, caseSensitive) { + throw new Components.Exception( + `${this.constructor.name} does not implement getCardsFromProperty.`, + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + getMailListFromName(name) { + for (let list of this.lists.values()) { + if (list.name.toLowerCase() == name.toLowerCase()) { + return new lazy.AddrBookMailingList( + list.uid, + this, + list.name, + list.nickName, + list.description + ).asDirectory; + } + } + return null; + } + deleteDirectory(directory) { + if (this._readOnly) { + throw new Components.Exception( + "Directory is read-only", + Cr.NS_ERROR_FAILURE + ); + } + + let list = this.lists.get(directory.UID); + list = new lazy.AddrBookMailingList( + list.uid, + this, + list.name, + list.nickName, + list.description + ); + + this.deleteList(directory.UID); + + Services.obs.notifyObservers( + list.asDirectory, + "addrbook-list-deleted", + this.UID + ); + } + hasCard(card) { + return this.lists.has(card.UID) || this.cards.has(card.UID); + } + hasDirectory(dir) { + return this.lists.has(dir.UID); + } + hasMailListWithName(name) { + return this.getMailListFromName(name) != null; + } + addCard(card) { + return this.dropCard(card, false); + } + modifyCard(card) { + if (this._readOnly && !this._overrideReadOnly) { + throw new Components.Exception( + "Directory is read-only", + Cr.NS_ERROR_FAILURE + ); + } + + let oldProperties = this.loadCardProperties(card.UID); + let newProperties = this.prepareToSaveCard(card); + + let allProperties = new Set(oldProperties.keys()); + for (let key of newProperties.keys()) { + allProperties.add(key); + } + + if (this.hasOwnProperty("cards")) { + this.cards.set(card.UID, newProperties); + } + this.saveCardProperties(card.UID, newProperties); + + let changeData = {}; + for (let name of allProperties) { + if (name == "LastModifiedDate") { + continue; + } + + let oldValue = oldProperties.get(name) || null; + let newValue = newProperties.get(name) || null; + if (oldValue != newValue) { + changeData[name] = { oldValue, newValue }; + } + } + + // Increment this preference if one or both of these properties change. + // This will cause the UI to throw away cached values. + if ("DisplayName" in changeData || "PreferDisplayName" in changeData) { + Services.prefs.setIntPref( + "mail.displayname.version", + Services.prefs.getIntPref("mail.displayname.version", 0) + 1 + ); + } + + // Send the card as it is in this directory, not as passed to this function. + let newCard = this.getCard(card.UID); + Services.obs.notifyObservers(newCard, "addrbook-contact-updated", this.UID); + + Services.obs.notifyObservers( + newCard, + "addrbook-contact-properties-updated", + JSON.stringify(changeData) + ); + + // Return the card, even though the interface says not to, because + // subclasses may want it. + return newCard; + } + deleteCards(cards) { + if (this._readOnly && !this._overrideReadOnly) { + throw new Components.Exception( + "Directory is read-only", + Cr.NS_ERROR_FAILURE + ); + } + + if (cards === null) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_POINTER); + } + + let updateDisplayNameVersion = false; + for (let card of cards) { + updateDisplayNameVersion = updateDisplayNameVersion || card.displayName; + // TODO: delete photo if there is one + this.deleteCard(card.UID); + if (this.hasOwnProperty("cards")) { + this.cards.delete(card.UID); + } + } + + // Increment this preference if one or more cards has a display name. + // This will cause the UI to throw away cached values. + if (updateDisplayNameVersion) { + Services.prefs.setIntPref( + "mail.displayname.version", + Services.prefs.getIntPref("mail.displayname.version", 0) + 1 + ); + } + + for (let card of cards) { + Services.obs.notifyObservers(card, "addrbook-contact-deleted", this.UID); + card.directoryUID = null; + } + + // We could just delete all non-existent cards from list_cards, but a + // notification should be fired for each one. Let the list handle that. + for (let list of this.childNodes) { + list.deleteCards(cards); + } + } + dropCard(card, needToCopyCard) { + if (this._readOnly && !this._overrideReadOnly) { + throw new Components.Exception( + "Directory is read-only", + Cr.NS_ERROR_FAILURE + ); + } + + if (!card.UID) { + throw new Error("Card must have a UID to be added to this directory."); + } + + let uid = needToCopyCard ? lazy.newUID() : card.UID; + let newProperties = this.prepareToSaveCard(card, uid); + if (card.directoryUID && card.directoryUID != this._uid) { + // These properties belong to a different directory. Don't keep them. + newProperties.delete("_etag"); + newProperties.delete("_href"); + } + + if (this.hasOwnProperty("cards")) { + this.cards.set(uid, newProperties); + } + this.saveCardProperties(uid, newProperties); + + // Increment this preference if the card has a display name. + // This will cause the UI to throw away cached values. + if (card.displayName) { + Services.prefs.setIntPref( + "mail.displayname.version", + Services.prefs.getIntPref("mail.displayname.version", 0) + 1 + ); + } + + let newCard = this.getCard(uid); + Services.obs.notifyObservers(newCard, "addrbook-contact-created", this.UID); + return newCard; + } + useForAutocomplete(identityKey) { + return ( + Services.prefs.getBoolPref("mail.enable_autocomplete") && + this.getBoolValue("enable_autocomplete", true) + ); + } + addMailList(list) { + if (this._readOnly) { + throw new Components.Exception( + "Directory is read-only", + Cr.NS_ERROR_FAILURE + ); + } + + if (!list.isMailList) { + throw Components.Exception( + "Can't add; not a mail list", + Cr.NS_ERROR_UNEXPECTED + ); + } + + // Check if the new name is empty. + if (!list.dirName) { + throw new Components.Exception( + `Mail list name must be set; list.dirName=${list.dirName}`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + + // Check if the new name contains 2 spaces. + if (list.dirName.match(" ")) { + throw new Components.Exception( + `Invalid mail list name: ${list.dirName}`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + + // Check if the new name contains the following special characters. + for (let char of ',;"<>') { + if (list.dirName.includes(char)) { + throw new Components.Exception( + `Invalid mail list name: ${list.dirName}`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + } + + let newList = new lazy.AddrBookMailingList( + lazy.newUID(), + this, + list.dirName || "", + list.listNickName || "", + list.description || "" + ); + this.saveList(newList); + + let newListDirectory = newList.asDirectory; + Services.obs.notifyObservers( + newListDirectory, + "addrbook-list-created", + this.UID + ); + return newListDirectory; + } + editMailListToDatabase(listCard) { + // Deliberately not implemented, this isn't a mailing list. + throw Components.Exception( + "editMailListToDatabase not relevant here", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + copyMailList(srcList) { + // Deliberately not implemented, this isn't a mailing list. + throw Components.Exception( + "copyMailList not relevant here", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + getIntValue(name, defaultValue) { + return this._prefBranch + ? this._prefBranch.getIntPref(name, defaultValue) + : defaultValue; + } + getBoolValue(name, defaultValue) { + return this._prefBranch + ? this._prefBranch.getBoolPref(name, defaultValue) + : defaultValue; + } + getStringValue(name, defaultValue) { + return this._prefBranch + ? this._prefBranch.getStringPref(name, defaultValue) + : defaultValue; + } + getLocalizedStringValue(name, defaultValue) { + if (!this._prefBranch) { + return defaultValue; + } + if (this._prefBranch.getPrefType(name) == Ci.nsIPrefBranch.PREF_INVALID) { + return defaultValue; + } + try { + return this._prefBranch.getComplexValue(name, Ci.nsIPrefLocalizedString) + .data; + } catch (e) { + // getComplexValue doesn't work with autoconfig. + return this._prefBranch.getStringPref(name); + } + } + setIntValue(name, value) { + this._prefBranch.setIntPref(name, value); + } + setBoolValue(name, value) { + this._prefBranch.setBoolPref(name, value); + } + setStringValue(name, value) { + this._prefBranch.setStringPref(name, value); + } + setLocalizedStringValue(name, value) { + let valueLocal = Cc["@mozilla.org/pref-localizedstring;1"].createInstance( + Ci.nsIPrefLocalizedString + ); + valueLocal.data = value; + this._prefBranch.setComplexValue( + name, + Ci.nsIPrefLocalizedString, + valueLocal + ); + } +} diff --git a/comm/mailnews/addrbook/modules/AddrBookMailingList.jsm b/comm/mailnews/addrbook/modules/AddrBookMailingList.jsm new file mode 100644 index 0000000000..31d16e93aa --- /dev/null +++ b/comm/mailnews/addrbook/modules/AddrBookMailingList.jsm @@ -0,0 +1,420 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["AddrBookMailingList"]; + +/* Prototype for mailing lists. A mailing list can appear as nsIAbDirectory + * or as nsIAbCard. Here we keep all relevant information in the class itself + * and fulfill each interface on demand. This will make more sense and be + * a lot neater once we stop using two XPCOM interfaces for one job. */ + +function AddrBookMailingList(uid, parent, name, nickName, description) { + this._uid = uid; + this._parent = parent; + this._name = name; + this._nickName = nickName; + this._description = description; +} +AddrBookMailingList.prototype = { + get asDirectory() { + let self = this; + return { + QueryInterface: ChromeUtils.generateQI(["nsIAbDirectory"]), + classID: Components.ID("{e96ee804-0bd3-472f-81a6-8a9d65277ad3}"), + + get readOnly() { + return self._parent._readOnly; + }, + get isRemote() { + return self._parent.isRemote; + }, + get isSecure() { + return self._parent.isSecure; + }, + get propertiesChromeURI() { + return "chrome://messenger/content/addressbook/abAddressBookNameDialog.xhtml"; + }, + get UID() { + return self._uid; + }, + get URI() { + return `${self._parent.URI}/${self._uid}`; + }, + get dirName() { + return self._name; + }, + set dirName(value) { + self._name = value; + }, + get listNickName() { + return self._nickName; + }, + set listNickName(value) { + self._nickName = value; + }, + get description() { + return self._description; + }, + set description(value) { + self._description = value; + }, + get isMailList() { + return true; + }, + get childNodes() { + return []; + }, + get childCards() { + let selectStatement = self._parent._dbConnection.createStatement( + "SELECT card FROM list_cards WHERE list = :list ORDER BY oid" + ); + selectStatement.params.list = self._uid; + let results = []; + while (selectStatement.executeStep()) { + results.push(self._parent.getCard(selectStatement.row.card)); + } + selectStatement.finalize(); + return results; + }, + get supportsMailingLists() { + return false; + }, + + search(query, string, listener) { + if (!listener) { + return; + } + if (!query) { + listener.onSearchFinished(Cr.NS_ERROR_FAILURE, true, null, ""); + return; + } + if (query[0] == "?") { + query = query.substring(1); + } + + let results = this.childCards; + + // Process the query string into a tree of conditions to match. + let lispRegexp = /^\((and|or|not|([^\)]*)(\)+))/; + let index = 0; + let rootQuery = { children: [], op: "or" }; + let currentQuery = rootQuery; + + while (true) { + let match = lispRegexp.exec(query.substring(index)); + if (!match) { + break; + } + index += match[0].length; + + if (["and", "or", "not"].includes(match[1])) { + // For the opening bracket, step down a level. + let child = { + parent: currentQuery, + children: [], + op: match[1], + }; + currentQuery.children.push(child); + currentQuery = child; + } else { + let [name, condition, value] = match[2].split(","); + currentQuery.children.push({ + name, + condition, + value: decodeURIComponent(value).toLowerCase(), + }); + + // For each closing bracket except the first, step up a level. + for (let i = match[3].length - 1; i > 0; i--) { + currentQuery = currentQuery.parent; + } + } + } + + results = results.filter(card => { + let properties = card._properties; + let matches = b => { + if ("condition" in b) { + let { name, condition, value } = b; + if (name == "IsMailList" && condition == "=") { + return value == "true"; + } + + if (!properties.has(name)) { + return condition == "!ex"; + } + if (condition == "ex") { + return true; + } + + let cardValue = properties.get(name).toLowerCase(); + switch (condition) { + case "=": + return cardValue == value; + case "!=": + return cardValue != value; + case "lt": + return cardValue < value; + case "gt": + return cardValue > value; + case "bw": + return cardValue.startsWith(value); + case "ew": + return cardValue.endsWith(value); + case "c": + return cardValue.includes(value); + case "!c": + return !cardValue.includes(value); + case "~=": + case "regex": + default: + return false; + } + } + if (b.op == "or") { + return b.children.some(bb => matches(bb)); + } + if (b.op == "and") { + return b.children.every(bb => matches(bb)); + } + if (b.op == "not") { + return !matches(b.children[0]); + } + return false; + }; + + return matches(rootQuery); + }, this); + + for (let card of results) { + listener.onSearchFoundCard(card); + } + listener.onSearchFinished(Cr.NS_OK, true, null, ""); + }, + addCard(card) { + if (this.readOnly) { + throw new Components.Exception( + "Directory is read-only", + Cr.NS_ERROR_FAILURE + ); + } + + if (!card.primaryEmail) { + return card; + } + if (!self._parent.hasCard(card)) { + card = self._parent.addCard(card); + } + let insertStatement = self._parent._dbConnection.createStatement( + "REPLACE INTO list_cards (list, card) VALUES (:list, :card)" + ); + insertStatement.params.list = self._uid; + insertStatement.params.card = card.UID; + insertStatement.execute(); + Services.obs.notifyObservers( + card, + "addrbook-list-member-added", + self._uid + ); + insertStatement.finalize(); + return card; + }, + deleteCards(cards) { + if (this.readOnly) { + throw new Components.Exception( + "Directory is read-only", + Cr.NS_ERROR_FAILURE + ); + } + + let deleteCardStatement = self._parent._dbConnection.createStatement( + "DELETE FROM list_cards WHERE list = :list AND card = :card" + ); + for (let card of cards) { + deleteCardStatement.params.list = self._uid; + deleteCardStatement.params.card = card.UID; + deleteCardStatement.execute(); + if (self._parent._dbConnection.affectedRows) { + Services.obs.notifyObservers( + card, + "addrbook-list-member-removed", + self._uid + ); + } + deleteCardStatement.reset(); + } + deleteCardStatement.finalize(); + }, + dropCard(card, needToCopyCard) { + if (this.readOnly) { + throw new Components.Exception( + "Directory is read-only", + Cr.NS_ERROR_FAILURE + ); + } + + if (needToCopyCard) { + card = self._parent.dropCard(card, true); + } + this.addCard(card); + Services.obs.notifyObservers( + card, + "addrbook-list-member-added", + self._uid + ); + }, + editMailListToDatabase(listCard) { + if (this.readOnly) { + throw new Components.Exception( + "Directory is read-only", + Cr.NS_ERROR_FAILURE + ); + } + + // Check if the new name is empty. + if (!self._name) { + throw new Components.Exception( + "Invalid mailing list name", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + + // Check if the new name contains 2 spaces. + if (self._name.match(" ")) { + throw new Components.Exception( + "Invalid mailing list name", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + + // Check if the new name contains the following special characters. + for (let char of ',;"<>') { + if (self._name.includes(char)) { + throw new Components.Exception( + "Invalid mailing list name", + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + } + + self._parent.saveList(self); + Services.obs.notifyObservers( + this, + "addrbook-list-updated", + self._parent.UID + ); + }, + hasMailListWithName(name) { + return false; + }, + getMailListFromName(name) { + return null; + }, + }; + }, + get asCard() { + let self = this; + return { + QueryInterface: ChromeUtils.generateQI(["nsIAbCard"]), + classID: Components.ID("{1143991d-31cd-4ea6-9c97-c587d990d724}"), + + get UID() { + return self._uid; + }, + get isMailList() { + return true; + }, + get mailListURI() { + return `${self._parent.URI}/${self._uid}`; + }, + + get directoryUID() { + return self._parent.UID; + }, + get firstName() { + return ""; + }, + get lastName() { + return self._name; + }, + get displayName() { + return self._name; + }, + set displayName(value) { + self._name = value; + }, + get primaryEmail() { + return ""; + }, + get emailAddresses() { + // NOT the members of this list. + return []; + }, + + generateName(generateFormat) { + return self._name; + }, + getProperty(name, defaultValue) { + switch (name) { + case "NickName": + return self._nickName; + case "Notes": + return self._description; + } + return defaultValue; + }, + setProperty(name, value) { + switch (name) { + case "NickName": + self._nickName = value; + break; + case "Notes": + self._description = value; + break; + } + }, + equals(card) { + return self._uid == card.UID; + }, + hasEmailAddress(emailAddress) { + return false; + }, + get properties() { + const entries = [ + ["DisplayName", this.displayName], + ["NickName", this.getProperty("NickName", "")], + ["Notes", this.getProperty("Notes", "")], + ]; + let props = []; + for (const [name, value] of entries) { + props.push({ + get name() { + return name; + }, + get value() { + return value; + }, + QueryInterface: ChromeUtils.generateQI(["nsIProperty"]), + }); + } + return props; + }, + get supportsVCard() { + return false; + }, + get vCardProperties() { + return null; + }, + translateTo(type) { + // Get nsAbCardProperty to do the work, the code is in C++ anyway. + let cardCopy = Cc[ + "@mozilla.org/addressbook/cardproperty;1" + ].createInstance(Ci.nsIAbCard); + cardCopy.UID = this.UID; + cardCopy.copy(this); + return cardCopy.translateTo(type); + }, + }; + }, +}; diff --git a/comm/mailnews/addrbook/modules/AddrBookManager.jsm b/comm/mailnews/addrbook/modules/AddrBookManager.jsm new file mode 100644 index 0000000000..6e15a4c971 --- /dev/null +++ b/comm/mailnews/addrbook/modules/AddrBookManager.jsm @@ -0,0 +1,608 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["AddrBookManager"]; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + compareAddressBooks: "resource:///modules/AddrBookUtils.jsm", + MailGlue: "resource:///modules/MailGlue.jsm", +}); + +/** Test for valid directory URIs. */ +const URI_REGEXP = /^([\w-]+):\/\/([\w\.-]*)([/:].*|$)/; + +/** + * When initialized, a map of nsIAbDirectory objects. Keys to this map are + * the directories' URIs. + */ +let store = null; + +/** Valid address book types. This differs by operating system. */ +let types = ["jsaddrbook", "jscarddav", "moz-abldapdirectory"]; +if (AppConstants.platform == "macosx") { + types.push("moz-abosxdirectory"); +} else if (AppConstants.platform == "win") { + types.push("moz-aboutlookdirectory"); +} + +/** + * A pre-sorted list of directories in the right order, to be returned by + * AddrBookManager.directories. That function is called a lot, and there's + * no need to sort the list every time. + * + * Call updateSortedDirectoryList after `store` changes and before any + * notifications happen. + */ +let sortedDirectoryList = []; +function updateSortedDirectoryList() { + sortedDirectoryList = [...store.values()]; + sortedDirectoryList.sort(lazy.compareAddressBooks); +} + +/** + * Initialise an address book directory by URI. + * + * @param {string} uri - URI for the directory. + * @param {boolean} shouldStore - Whether to keep a reference to this address + * book in the store. + * @returns {nsIAbDirectory} + */ +function createDirectoryObject(uri, shouldStore = false) { + let uriParts = URI_REGEXP.exec(uri); + if (!uriParts) { + throw Components.Exception( + `Unexpected uri: ${uri}`, + Cr.NS_ERROR_MALFORMED_URI + ); + } + + let [, scheme] = uriParts; + let dir = Cc[ + `@mozilla.org/addressbook/directory;1?type=${scheme}` + ].createInstance(Ci.nsIAbDirectory); + + try { + if (shouldStore) { + // This must happen before .init is called, or the OS X provider breaks + // in some circumstances. If .init fails, we'll remove it again. + // The Outlook provider also needs this since during the initialisation + // of the top-most directory, contained mailing lists already need + // to loop that directory. + store.set(uri, dir); + } + dir.init(uri); + } catch (ex) { + if (shouldStore) { + store.delete(uri); + } + throw ex; + } + + return dir; +} + +/** + * Read the preferences and create any address books defined there. + */ +function ensureInitialized() { + if (store !== null) { + return; + } + if (lazy.MailGlue.isToolboxProcess) { + throw new Components.Exception( + "AddrBookManager tried to start in the Developer Tools process!", + Cr.NS_ERROR_UNEXPECTED + ); + } + + store = new Map(); + + for (let pref of Services.prefs.getChildList("ldap_2.servers.")) { + try { + if (pref.endsWith(".uri")) { + let uri = Services.prefs.getStringPref(pref); + if (uri.startsWith("ldap://") || uri.startsWith("ldaps://")) { + let prefName = pref.substring(0, pref.length - 4); + + uri = `moz-abldapdirectory://${prefName}`; + createDirectoryObject(uri, true); + } + } else if (pref.endsWith(".dirType")) { + let prefName = pref.substring(0, pref.length - 8); + let dirType = Services.prefs.getIntPref(pref); + let fileName = Services.prefs.getStringPref(`${prefName}.filename`, ""); + let uri = Services.prefs.getStringPref(`${prefName}.uri`, ""); + + switch (dirType) { + case Ci.nsIAbManager.MAPI_DIRECTORY_TYPE: + if ( + Cu.isInAutomation || + Services.env.exists("XPCSHELL_TEST_PROFILE_DIR") + ) { + // Don't load the OS Address Book in tests. + break; + } + if (Services.prefs.getIntPref(`${prefName}.position`, 1) < 1) { + // Migration: the previous address book manager set the position + // value to 0 to indicate the removal of an address book. + Services.prefs.clearUserPref(`${prefName}.position`); + Services.prefs.setIntPref(pref, -1); + break; + } + if (AppConstants.platform == "macosx") { + createDirectoryObject(uri, true); + } else if (AppConstants.platform == "win") { + let outlookInterface = Cc[ + "@mozilla.org/addressbook/outlookinterface;1" + ].getService(Ci.nsIAbOutlookInterface); + for (let folderURI of outlookInterface.getFolderURIs(uri)) { + createDirectoryObject(folderURI, true); + } + } + break; + case Ci.nsIAbManager.JS_DIRECTORY_TYPE: + if (fileName) { + let uri = `jsaddrbook://${fileName}`; + createDirectoryObject(uri, true); + } + break; + case Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE: + if (fileName) { + let uri = `jscarddav://${fileName}`; + createDirectoryObject(uri, true); + } + break; + } + } + } catch (ex) { + console.error(ex); + } + } + + updateSortedDirectoryList(); +} + +// Force the manager to shut down. For tests only. +Services.obs.addObserver(async () => { + // Allow directories to tidy up. + for (let directory of store.values()) { + await directory.cleanUp(); + } + // Clear the store. The next call to ensureInitialized will recreate it. + store = null; + Services.obs.notifyObservers(null, "addrbook-reloaded"); +}, "addrbook-reload"); + +/** Cache for the cardForEmailAddress function, and timer to clear it. */ +let addressCache = new Map(); +let addressCacheTimer = null; + +// Throw away cached cards if the display name properties change, so we can +// get the updated version of the card that changed. +Services.prefs.addObserver("mail.displayname.version", () => { + addressCache.clear(); + Services.obs.notifyObservers(null, "addrbook-displayname-changed"); +}); + +// When this prefence has been updated, we need to update the +// mail.displayname.version, which notifies it's preference observer (above). +// This will then notify the addrbook-displayname-changed observer, and change +// the displayname in the thread tree and message header. +Services.prefs.addObserver("mail.showCondensedAddresses", () => { + Services.prefs.setIntPref( + "mail.displayname.version", + Services.prefs.getIntPref("mail.displayname.version") + 1 + ); +}); + +/** + * @implements {nsIAbManager} + * @implements {nsICommandLineHandler} + */ +function AddrBookManager() {} +AddrBookManager.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsIAbManager", + "nsICommandLineHandler", + ]), + classID: Components.ID("{224d3ef9-d81c-4d94-8826-a79a5835af93}"), + + /* nsIAbManager */ + + get directories() { + ensureInitialized(); + return sortedDirectoryList.slice(); + }, + getDirectory(uri) { + if (uri.startsWith("moz-abdirectory://")) { + throw new Components.Exception( + "The root address book no longer exists", + Cr.NS_ERROR_FAILURE + ); + } + + ensureInitialized(); + if (store.has(uri)) { + return store.get(uri); + } + + let uriParts = URI_REGEXP.exec(uri); + if (!uriParts) { + throw Components.Exception( + `Unexpected uri: ${uri}`, + Cr.NS_ERROR_MALFORMED_URI + ); + } + let [, scheme, fileName, tail] = uriParts; + if (tail && types.includes(scheme)) { + if ( + (scheme == "jsaddrbook" && tail.startsWith("/")) || + scheme == "moz-aboutlookdirectory" + ) { + let parent; + if (scheme == "jsaddrbook") { + parent = this.getDirectory(`${scheme}://${fileName}`); + } else { + parent = this.getDirectory(`${scheme}:///${tail.split("/")[1]}`); + } + for (let list of parent.childNodes) { + list.QueryInterface(Ci.nsIAbDirectory); + if (list.URI == uri) { + return list; + } + } + throw Components.Exception( + `No ${scheme} directory for uri=${uri}`, + Cr.NS_ERROR_UNEXPECTED + ); + } else if (scheme == "jscarddav") { + throw Components.Exception( + `No ${scheme} directory for uri=${uri}`, + Cr.NS_ERROR_UNEXPECTED + ); + } + // `tail` could point to a mailing list. + return createDirectoryObject(uri); + } + throw Components.Exception( + `No directory for uri=${uri}`, + Cr.NS_ERROR_FAILURE + ); + }, + getDirectoryFromId(dirPrefId) { + ensureInitialized(); + for (let dir of store.values()) { + if (dir.dirPrefId == dirPrefId) { + return dir; + } + } + return null; + }, + getDirectoryFromUID(uid) { + ensureInitialized(); + for (let dir of store.values()) { + if (dir.UID == uid) { + return dir; + } + } + return null; + }, + getMailListFromName(name) { + ensureInitialized(); + for (let dir of store.values()) { + let hit = dir.getMailListFromName(name); + if (hit) { + return hit; + } + } + return null; + }, + newAddressBook(dirName, uri, type, uid) { + function ensureUniquePrefName() { + let leafName = dirName.replace(/\W/g, ""); + if (!leafName) { + leafName = "_nonascii"; + } + + let existingNames = Array.from(store.values(), dir => dir.dirPrefId); + let uniqueCount = 0; + prefName = `ldap_2.servers.${leafName}`; + while (existingNames.includes(prefName)) { + prefName = `ldap_2.servers.${leafName}_${++uniqueCount}`; + } + } + + if (!dirName) { + throw new Components.Exception( + "dirName must be specified", + Cr.NS_ERROR_INVALID_ARG + ); + } + if (uid && this.getDirectoryFromUID(uid)) { + throw new Components.Exception( + `An address book with the UID ${uid} already exists`, + Cr.NS_ERROR_ABORT + ); + } + + let prefName; + ensureInitialized(); + + switch (type) { + case Ci.nsIAbManager.LDAP_DIRECTORY_TYPE: { + let file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("ldap.sqlite"); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644); + + ensureUniquePrefName(); + Services.prefs.setStringPref(`${prefName}.description`, dirName); + Services.prefs.setStringPref(`${prefName}.filename`, file.leafName); + Services.prefs.setStringPref(`${prefName}.uri`, uri); + if (uid) { + Services.prefs.setStringPref(`${prefName}.uid`, uid); + } + + uri = `moz-abldapdirectory://${prefName}`; + let dir = createDirectoryObject(uri, true); + updateSortedDirectoryList(); + Services.obs.notifyObservers(dir, "addrbook-directory-created"); + break; + } + case Ci.nsIAbManager.MAPI_DIRECTORY_TYPE: { + if (AppConstants.platform == "macosx") { + uri = "moz-abosxdirectory:///"; + if (store.has(uri)) { + throw Components.Exception( + `Can't create new ab of type=${type} - already exists`, + Cr.NS_ERROR_UNEXPECTED + ); + } + prefName = "ldap_2.servers.osx"; + } else if (AppConstants.platform == "win") { + uri = "moz-aboutlookdirectory:///"; + if (store.has(uri)) { + throw Components.Exception( + `Can't create new ab of type=${type} - already exists`, + Cr.NS_ERROR_UNEXPECTED + ); + } + prefName = "ldap_2.servers.outlook"; + } else { + throw Components.Exception( + "Can't create new ab of type=MAPI_DIRECTORY_TYPE", + Cr.NS_ERROR_UNEXPECTED + ); + } + + Services.prefs.setIntPref( + `${prefName}.dirType`, + Ci.nsIAbManager.MAPI_DIRECTORY_TYPE + ); + Services.prefs.setStringPref( + `${prefName}.description`, + "chrome://messenger/locale/addressbook/addressBook.properties" + ); + Services.prefs.setStringPref(`${prefName}.uri`, uri); + if (uid) { + Services.prefs.setStringPref(`${prefName}.uid`, uid); + } + + if (AppConstants.platform == "macosx") { + let dir = createDirectoryObject(uri, true); + updateSortedDirectoryList(); + Services.obs.notifyObservers(dir, "addrbook-directory-created"); + } else if (AppConstants.platform == "win") { + let outlookInterface = Cc[ + "@mozilla.org/addressbook/outlookinterface;1" + ].getService(Ci.nsIAbOutlookInterface); + for (let folderURI of outlookInterface.getFolderURIs(uri)) { + let dir = createDirectoryObject(folderURI, true); + updateSortedDirectoryList(); + Services.obs.notifyObservers(dir, "addrbook-directory-created"); + } + } + break; + } + case Ci.nsIAbManager.JS_DIRECTORY_TYPE: + case Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE: { + let file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("abook.sqlite"); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644); + + ensureUniquePrefName(); + Services.prefs.setStringPref(`${prefName}.description`, dirName); + Services.prefs.setIntPref(`${prefName}.dirType`, type); + Services.prefs.setStringPref(`${prefName}.filename`, file.leafName); + if (uid) { + Services.prefs.setStringPref(`${prefName}.uid`, uid); + } + + let scheme = + type == Ci.nsIAbManager.JS_DIRECTORY_TYPE + ? "jsaddrbook" + : "jscarddav"; + uri = `${scheme}://${file.leafName}`; + let dir = createDirectoryObject(uri, true); + updateSortedDirectoryList(); + Services.obs.notifyObservers(dir, "addrbook-directory-created"); + break; + } + default: + throw Components.Exception( + `Unexpected directory type: ${type}`, + Cr.NS_ERROR_UNEXPECTED + ); + } + + return prefName; + }, + addAddressBook(dir) { + if ( + !dir.URI || + !dir.dirName || + !dir.UID || + dir.isMailList || + dir.isQuery || + dir.dirPrefId + ) { + throw new Components.Exception( + "Invalid directory", + Cr.NS_ERROR_INVALID_ARG + ); + } + + ensureInitialized(); + if (store.has(dir.URI)) { + throw new Components.Exception( + "Directory already exists", + Cr.NS_ERROR_UNEXPECTED + ); + } + + store.set(dir.URI, dir); + updateSortedDirectoryList(); + Services.obs.notifyObservers(dir, "addrbook-directory-created"); + }, + deleteAddressBook(uri) { + let uriParts = URI_REGEXP.exec(uri); + if (!uriParts) { + throw Components.Exception("", Cr.NS_ERROR_MALFORMED_URI); + } + + let [, scheme, fileName, tail] = uriParts; + if (tail && tail.startsWith("/")) { + let dir; + if (scheme == "jsaddrbook") { + dir = store.get(`${scheme}://${fileName}`); + } else if (scheme == "moz-aboutlookdirectory") { + dir = store.get(`${scheme}:///${tail.split("/")[1]}`); + } + let list = this.getDirectory(uri); + if (dir && list) { + dir.deleteDirectory(list); + return; + } + } + + let dir = store.get(uri); + if (!dir) { + throw new Components.Exception( + `Address book not found: ${uri}`, + Cr.NS_ERROR_UNEXPECTED + ); + } + + let prefName = dir.dirPrefId; + if (prefName) { + let dirType = Services.prefs.getIntPref(`${prefName}.dirType`, 0); + fileName = dir.fileName; + + // Deleting the built-in address books is very bad. + if (["ldap_2.servers.pab", "ldap_2.servers.history"].includes(prefName)) { + throw new Components.Exception( + "Refusing to delete a built-in address book", + Cr.NS_ERROR_FAILURE + ); + } + + for (let name of Services.prefs.getChildList(`${prefName}.`)) { + Services.prefs.clearUserPref(name); + } + if (dirType == Ci.nsIAbManager.MAPI_DIRECTORY_TYPE) { + // The prefs for this directory type are defaults. Setting the dirType + // to -1 ensures the directory is ignored. + Services.prefs.setIntPref(`${prefName}.dirType`, -1); + } + } + + store.delete(uri); + updateSortedDirectoryList(); + + // Clear this reference to the deleted address book. + if (Services.prefs.getStringPref("mail.collect_addressbook") == uri) { + Services.prefs.clearUserPref("mail.collect_addressbook"); + } + + dir.cleanUp().then(() => { + if (fileName) { + let file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append(fileName); + if (file.exists()) { + file.remove(false); + } + } + + Services.obs.notifyObservers(dir, "addrbook-directory-deleted"); + }); + }, + mailListNameExists(name) { + ensureInitialized(); + for (let dir of store.values()) { + if (dir.hasMailListWithName(name)) { + return true; + } + } + return false; + }, + /** + * Finds out if the directory name already exists. + * + * @param {string} name - The name of a directory to check for. + */ + directoryNameExists(name) { + ensureInitialized(); + for (let dir of store.values()) { + if (dir.dirName.toLowerCase() === name.toLowerCase()) { + return true; + } + } + return false; + }, + cardForEmailAddress(emailAddress) { + if (!emailAddress) { + return null; + } + + if (addressCacheTimer) { + lazy.clearTimeout(addressCacheTimer); + } + addressCacheTimer = lazy.setTimeout(() => { + addressCacheTimer = null; + addressCache.clear(); + }, 60000); + + if (addressCache.has(emailAddress)) { + return addressCache.get(emailAddress); + } + + for (let directory of sortedDirectoryList) { + try { + let card = directory.cardForEmailAddress(emailAddress); + if (card) { + addressCache.set(emailAddress, card); + return card; + } + } catch (ex) { + // Directories can throw, that's okay. + } + } + + addressCache.set(emailAddress, null); + return null; + }, +}; diff --git a/comm/mailnews/addrbook/modules/AddrBookUtils.jsm b/comm/mailnews/addrbook/modules/AddrBookUtils.jsm new file mode 100644 index 0000000000..aea0c152ad --- /dev/null +++ b/comm/mailnews/addrbook/modules/AddrBookUtils.jsm @@ -0,0 +1,522 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = [ + "exportAttributes", + "AddrBookUtils", + "compareAddressBooks", + "newUID", + "SimpleEnumerator", +]; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { MailStringUtils } = ChromeUtils.import( + "resource:///modules/MailStringUtils.jsm" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetters(lazy, { + attrMapService: [ + "@mozilla.org/addressbook/ldap-attribute-map-service;1", + "nsIAbLDAPAttributeMapService", + ], +}); + +function SimpleEnumerator(elements) { + this._elements = elements; + this._position = 0; +} +SimpleEnumerator.prototype = { + hasMoreElements() { + return this._position < this._elements.length; + }, + getNext() { + if (this.hasMoreElements()) { + return this._elements[this._position++]; + } + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + }, + QueryInterface: ChromeUtils.generateQI(["nsISimpleEnumerator"]), + *[Symbol.iterator]() { + while (this.hasMoreElements()) { + yield this.getNext(); + } + }, +}; + +function newUID() { + return Services.uuid.generateUUID().toString().substring(1, 37); +} + +let abSortOrder = { + [Ci.nsIAbManager.JS_DIRECTORY_TYPE]: 1, + [Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE]: 2, + [Ci.nsIAbManager.LDAP_DIRECTORY_TYPE]: 3, + [Ci.nsIAbManager.ASYNC_DIRECTORY_TYPE]: 3, + [Ci.nsIAbManager.MAPI_DIRECTORY_TYPE]: 4, +}; +let abNameComparer = new Intl.Collator(undefined, { numeric: true }); + +/** + * Comparator for address books. Any UI that lists address books should use + * this order, although generally speaking, using nsIAbManager.directories is + * all that is required to get the order. + * + * Note that directories should not be compared with mailing lists in this way, + * however two mailing lists with the same parent can be safely compared. + * + * @param {nsIAbDirectory} a + * @param {nsIAbDirectory} b + * @returns {integer} + */ +function compareAddressBooks(a, b) { + if (a.isMailList != b.isMailList) { + throw Components.Exception( + "Tried to compare a mailing list with a directory", + Cr.NS_ERROR_UNEXPECTED + ); + } + + // Only compare the names of mailing lists. + if (a.isMailList) { + return abNameComparer.compare(a.dirName, b.dirName); + } + + // The Personal Address Book is first and Collected Addresses last. + let aPrefId = a.dirPrefId; + let bPrefId = b.dirPrefId; + + if (aPrefId == "ldap_2.servers.pab" || bPrefId == "ldap_2.servers.history") { + return -1; + } + if (bPrefId == "ldap_2.servers.pab" || aPrefId == "ldap_2.servers.history") { + return 1; + } + + // Order remaining directories by type. + let aType = a.dirType; + let bType = b.dirType; + + if (aType != bType) { + return abSortOrder[aType] - abSortOrder[bType]; + } + + // Order directories of the same type by name, case-insensitively. + return abNameComparer.compare(a.dirName, b.dirName); +} + +const exportAttributes = [ + ["FirstName", 2100], + ["LastName", 2101], + ["DisplayName", 2102], + ["NickName", 2103], + ["PrimaryEmail", 2104], + ["SecondEmail", 2105], + ["_AimScreenName", 2136], + ["LastModifiedDate", 0], + ["WorkPhone", 2106], + ["WorkPhoneType", 0], + ["HomePhone", 2107], + ["HomePhoneType", 0], + ["FaxNumber", 2108], + ["FaxNumberType", 0], + ["PagerNumber", 2109], + ["PagerNumberType", 0], + ["CellularNumber", 2110], + ["CellularNumberType", 0], + ["HomeAddress", 2111], + ["HomeAddress2", 2112], + ["HomeCity", 2113], + ["HomeState", 2114], + ["HomeZipCode", 2115], + ["HomeCountry", 2116], + ["WorkAddress", 2117], + ["WorkAddress2", 2118], + ["WorkCity", 2119], + ["WorkState", 2120], + ["WorkZipCode", 2121], + ["WorkCountry", 2122], + ["JobTitle", 2123], + ["Department", 2124], + ["Company", 2125], + ["WebPage1", 2126], + ["WebPage2", 2127], + ["BirthYear", 2128], + ["BirthMonth", 2129], + ["BirthDay", 2130], + ["Custom1", 2131], + ["Custom2", 2132], + ["Custom3", 2133], + ["Custom4", 2134], + ["Notes", 2135], + ["AnniversaryYear", 0], + ["AnniversaryMonth", 0], + ["AnniversaryDay", 0], + ["SpouseName", 0], + ["FamilyName", 0], +]; +const LINEBREAK = AppConstants.platform == "win" ? "\r\n" : "\n"; + +var AddrBookUtils = { + compareAddressBooks, + async exportDirectory(directory) { + let systemCharset = "utf-8"; + if (AppConstants.platform == "win") { + // Some Windows applications (notably Outlook) still don't understand + // UTF-8 encoding when importing address books and instead use the current + // operating system encoding. We can get that encoding from the registry. + let registryKey = Cc[ + "@mozilla.org/windows-registry-key;1" + ].createInstance(Ci.nsIWindowsRegKey); + registryKey.open( + Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE, + "SYSTEM\\CurrentControlSet\\Control\\Nls\\CodePage", + Ci.nsIWindowsRegKey.ACCESS_READ + ); + let acpValue = registryKey.readStringValue("ACP"); + + // This data converts the registry key value into encodings that + // nsIConverterOutputStream understands. It is from + // https://github.com/hsivonen/encoding_rs/blob/c3eb642cdf3f17003b8dac95c8fff478568e46da/generate-encoding-data.py#L188 + systemCharset = + { + 866: "IBM866", + 874: "windows-874", + 932: "Shift_JIS", + 936: "GBK", + 949: "EUC-KR", + 950: "Big5", + 1200: "UTF-16LE", + 1201: "UTF-16BE", + 1250: "windows-1250", + 1251: "windows-1251", + 1252: "windows-1252", + 1253: "windows-1253", + 1254: "windows-1254", + 1255: "windows-1255", + 1256: "windows-1256", + 1257: "windows-1257", + 1258: "windows-1258", + 10000: "macintosh", + 10017: "x-mac-cyrillic", + 20866: "KOI8-R", + 20932: "EUC-JP", + 21866: "KOI8-U", + 28592: "ISO-8859-2", + 28593: "ISO-8859-3", + 28594: "ISO-8859-4", + 28595: "ISO-8859-5", + 28596: "ISO-8859-6", + 28597: "ISO-8859-7", + 28598: "ISO-8859-8", + 28600: "ISO-8859-10", + 28603: "ISO-8859-13", + 28604: "ISO-8859-14", + 28605: "ISO-8859-15", + 28606: "ISO-8859-16", + 38598: "ISO-8859-8-I", + 50221: "ISO-2022-JP", + 54936: "gb18030", + }[acpValue] || systemCharset; + } + + let filePicker = Cc["@mozilla.org/filepicker;1"].createInstance( + Ci.nsIFilePicker + ); + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/addressbook/addressBook.properties" + ); + + let title = bundle.formatStringFromName("ExportAddressBookNameTitle", [ + directory.dirName, + ]); + filePicker.init(Services.ww.activeWindow, title, Ci.nsIFilePicker.modeSave); + filePicker.defaultString = directory.dirName; + + let filterString; + // Since the list of file picker filters isn't fixed, keep track of which + // ones are added, so we can use them in the switch block below. + let activeFilters = []; + + // CSV + if (systemCharset != "utf-8") { + filterString = bundle.GetStringFromName("CSVFilesSysCharset"); + filePicker.appendFilter(filterString, "*.csv"); + activeFilters.push("CSVFilesSysCharset"); + } + filterString = bundle.GetStringFromName("CSVFilesUTF8"); + filePicker.appendFilter(filterString, "*.csv"); + activeFilters.push("CSVFilesUTF8"); + + // Tab separated + if (systemCharset != "utf-8") { + filterString = bundle.GetStringFromName("TABFilesSysCharset"); + filePicker.appendFilter(filterString, "*.tab; *.txt"); + activeFilters.push("TABFilesSysCharset"); + } + filterString = bundle.GetStringFromName("TABFilesUTF8"); + filePicker.appendFilter(filterString, "*.tab; *.txt"); + activeFilters.push("TABFilesUTF8"); + + // vCard + filterString = bundle.GetStringFromName("VCFFiles"); + filePicker.appendFilter(filterString, "*.vcf"); + activeFilters.push("VCFFiles"); + + // LDIF + filterString = bundle.GetStringFromName("LDIFFiles"); + filePicker.appendFilter(filterString, "*.ldi; *.ldif"); + activeFilters.push("LDIFFiles"); + + let rv = await new Promise(resolve => filePicker.open(resolve)); + if ( + rv == Ci.nsIFilePicker.returnCancel || + !filePicker.file || + !filePicker.file.path + ) { + return; + } + + if (rv == Ci.nsIFilePicker.returnReplace) { + if (filePicker.file.isFile()) { + filePicker.file.remove(false); + } + } + + let exportFile = filePicker.file.clone(); + let leafName = exportFile.leafName; + let output = ""; + let charset = "utf-8"; + + switch (activeFilters[filePicker.filterIndex]) { + case "CSVFilesSysCharset": + charset = systemCharset; + // Falls through. + case "CSVFilesUTF8": + if (!leafName.endsWith(".csv")) { + exportFile.leafName += ".csv"; + } + output = AddrBookUtils.exportDirectoryToDelimitedText(directory, ","); + break; + case "TABFilesSysCharset": + charset = systemCharset; + // Falls through. + case "TABFilesUTF8": + if (!leafName.endsWith(".txt") && !leafName.endsWith(".tab")) { + exportFile.leafName += ".txt"; + } + output = AddrBookUtils.exportDirectoryToDelimitedText(directory, "\t"); + break; + case "VCFFiles": + if (!leafName.endsWith(".vcf")) { + exportFile.leafName += ".vcf"; + } + output = AddrBookUtils.exportDirectoryToVCard(directory); + break; + case "LDIFFiles": + if (!leafName.endsWith(".ldi") && !leafName.endsWith(".ldif")) { + exportFile.leafName += ".ldif"; + } + output = AddrBookUtils.exportDirectoryToLDIF(directory); + break; + } + + if (charset == "utf-8") { + await IOUtils.writeUTF8(exportFile.path, output); + return; + } + + let outputFileStream = Cc[ + "@mozilla.org/network/file-output-stream;1" + ].createInstance(Ci.nsIFileOutputStream); + outputFileStream.init(exportFile, -1, -1, 0); + let outputStream = Cc[ + "@mozilla.org/intl/converter-output-stream;1" + ].createInstance(Ci.nsIConverterOutputStream); + outputStream.init(outputFileStream, charset); + outputStream.writeString(output); + outputStream.close(); + }, + exportDirectoryToDelimitedText(directory, delimiter) { + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/importMsgs.properties" + ); + let output = ""; + for (let i = 0; i < exportAttributes.length; i++) { + let [, plainTextStringID] = exportAttributes[i]; + if (plainTextStringID != 0) { + if (i != 0) { + output += delimiter; + } + output += bundle.GetStringFromID(plainTextStringID); + } + } + output += LINEBREAK; + for (let card of directory.childCards) { + if (card.isMailList) { + // .tab, .txt and .csv aren't able to export mailing lists. + // Use LDIF for that. + continue; + } + let propertyMap = card.supportsVCard + ? card.vCardProperties.toPropertyMap() + : null; + for (let i = 0; i < exportAttributes.length; i++) { + let [abPropertyName, plainTextStringID] = exportAttributes[i]; + if (plainTextStringID == 0) { + continue; + } + if (i != 0) { + output += delimiter; + } + let value; + if (propertyMap) { + value = propertyMap.get(abPropertyName); + } + if (!value) { + value = card.getProperty(abPropertyName, ""); + } + + // If a string contains at least one comma, tab, double quote or line + // break then we need to quote the entire string. Also if double quote + // is part of the string we need to quote the double quote(s) as well. + let needsQuotes = false; + if (value.includes('"')) { + needsQuotes = true; + value = value.replace(/"/g, '""'); + } else if (/[,\t\r\n]/.test(value)) { + needsQuotes = true; + } + if (needsQuotes) { + value = `"${value}"`; + } + + output += value; + } + output += LINEBREAK; + } + + return output; + }, + exportDirectoryToLDIF(directory) { + function appendProperty(name, value) { + if (!value) { + return; + } + // Follow RFC 2849 to determine if something is safe "as is" for LDIF. + // If not, base 64 encode it as UTF-8. + if ( + value[0] == " " || + value[0] == ":" || + value[0] == "<" || + /[\0\r\n\u0080-\uffff]/.test(value) + ) { + // Convert 16bit JavaScript string to a byteString, to make it work with + // btoa(). + let byteString = MailStringUtils.stringToByteString(value); + output += name + ":: " + btoa(byteString) + LINEBREAK; + } else { + output += name + ": " + value + LINEBREAK; + } + } + + function appendDNForCard(property, card, attrMap) { + let value = ""; + if (card.displayName) { + value += + attrMap.getFirstAttribute("DisplayName") + "=" + card.displayName; + } + if (card.primaryEmail) { + if (card.displayName) { + value += ","; + } + value += + attrMap.getFirstAttribute("PrimaryEmail") + "=" + card.primaryEmail; + } + appendProperty(property, value); + } + + let output = ""; + let attrMap = lazy.attrMapService.getMapForPrefBranch( + "ldap_2.servers.default.attrmap" + ); + + for (let card of directory.childCards) { + if (card.isMailList) { + appendDNForCard("dn", card, attrMap); + appendProperty("objectclass", "top"); + appendProperty("objectclass", "groupOfNames"); + appendProperty( + attrMap.getFirstAttribute("DisplayName"), + card.displayName + ); + if (card.getProperty("NickName", "")) { + appendProperty( + attrMap.getFirstAttribute("NickName"), + card.getProperty("NickName", "") + ); + } + if (card.getProperty("Notes", "")) { + appendProperty( + attrMap.getFirstAttribute("Notes"), + card.getProperty("Notes", "") + ); + } + let listAsDirectory = MailServices.ab.getDirectory(card.mailListURI); + for (let childCard of listAsDirectory.childCards) { + appendDNForCard("member", childCard, attrMap); + } + } else { + appendDNForCard("dn", card, attrMap); + appendProperty("objectclass", "top"); + appendProperty("objectclass", "person"); + appendProperty("objectclass", "organizationalPerson"); + appendProperty("objectclass", "inetOrgPerson"); + appendProperty("objectclass", "mozillaAbPersonAlpha"); + + let propertyMap = card.supportsVCard + ? card.vCardProperties.toPropertyMap() + : null; + for (let [abPropertyName] of exportAttributes) { + let attrName = attrMap.getFirstAttribute(abPropertyName); + if (attrName) { + let attrValue; + if (propertyMap) { + attrValue = propertyMap.get(abPropertyName); + } + if (!attrValue) { + attrValue = card.getProperty(abPropertyName, ""); + } + appendProperty(attrName, attrValue); + } + } + } + output += LINEBREAK; + } + + return output; + }, + exportDirectoryToVCard(directory) { + let output = ""; + for (let card of directory.childCards) { + if (!card.isMailList) { + // We don't know how to export mailing lists to vcf. + // Use LDIF for that. + output += decodeURIComponent(card.translateTo("vcard")); + } + } + return output; + }, + newUID, + SimpleEnumerator, +}; diff --git a/comm/mailnews/addrbook/modules/CardDAVDirectory.jsm b/comm/mailnews/addrbook/modules/CardDAVDirectory.jsm new file mode 100644 index 0000000000..e4361d53bb --- /dev/null +++ b/comm/mailnews/addrbook/modules/CardDAVDirectory.jsm @@ -0,0 +1,925 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["CardDAVDirectory"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +const { SQLiteDirectory } = ChromeUtils.import( + "resource:///modules/SQLiteDirectory.jsm" +); + +ChromeUtils.defineESModuleGetters(lazy, { + clearInterval: "resource://gre/modules/Timer.sys.mjs", + setInterval: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + CardDAVUtils: "resource:///modules/CardDAVUtils.jsm", + NotificationCallbacks: "resource:///modules/CardDAVUtils.jsm", + OAuth2Module: "resource:///modules/OAuth2Module.jsm", + OAuth2Providers: "resource:///modules/OAuth2Providers.jsm", + VCardProperties: "resource:///modules/VCardUtils.jsm", + VCardUtils: "resource:///modules/VCardUtils.jsm", +}); + +const PREFIX_BINDINGS = { + card: "urn:ietf:params:xml:ns:carddav", + cs: "http://calendarserver.org/ns/", + d: "DAV:", +}; +const NAMESPACE_STRING = Object.entries(PREFIX_BINDINGS) + .map(([prefix, url]) => `xmlns:${prefix}="${url}"`) + .join(" "); + +const log = console.createInstance({ + prefix: "carddav.sync", + maxLogLevel: "Warn", + maxLogLevelPref: "carddav.sync.loglevel", +}); + +/** + * Adds CardDAV sync to SQLiteDirectory. + */ +class CardDAVDirectory extends SQLiteDirectory { + /** nsIAbDirectory */ + + init(uri) { + super.init(uri); + + let serverURL = this._serverURL; + if (serverURL) { + // Google's server enforces some vCard 3.0-isms (or just fails badly if + // you don't provide exactly what it wants) so we use this property to + // determine when to do things differently. Cards from this directory + // inherit the same property. + if (this.getBoolValue("carddav.vcard3")) { + this._isGoogleCardDAV = true; + } else { + this._isGoogleCardDAV = serverURL.startsWith( + "https://www.googleapis.com/" + ); + if (this._isGoogleCardDAV) { + this.setBoolValue("carddav.vcard3", true); + } + } + + // If this directory is configured, start sync'ing with the server in 30s. + // Don't do this immediately, as this code runs at start-up and could + // impact performance if there are lots of changes to process. + if (this.getIntValue("carddav.syncinterval", 30) > 0) { + this._syncTimer = lazy.setTimeout(() => this.syncWithServer(), 30000); + } + } + + let uidsToSync = this.getStringValue("carddav.uidsToSync", ""); + if (uidsToSync) { + this._uidsToSync = new Set(uidsToSync.split(" ").filter(Boolean)); + this.setStringValue("carddav.uidsToSync", ""); + log.debug(`Retrieved list of cards to sync: ${uidsToSync}`); + } else { + this._uidsToSync = new Set(); + } + + let hrefsToRemove = this.getStringValue("carddav.hrefsToRemove", ""); + if (hrefsToRemove) { + this._hrefsToRemove = new Set(hrefsToRemove.split(" ").filter(Boolean)); + this.setStringValue("carddav.hrefsToRemove", ""); + log.debug(`Retrieved list of cards to remove: ${hrefsToRemove}`); + } else { + this._hrefsToRemove = new Set(); + } + } + async cleanUp() { + await super.cleanUp(); + + if (this._syncTimer) { + lazy.clearInterval(this._syncTimer); + this._syncTimer = null; + } + + if (this._uidsToSync.size) { + let uidsToSync = [...this._uidsToSync].join(" "); + this.setStringValue("carddav.uidsToSync", uidsToSync); + log.debug(`Stored list of cards to sync: ${uidsToSync}`); + } + if (this._hrefsToRemove.size) { + let hrefsToRemove = [...this._hrefsToRemove].join(" "); + this.setStringValue("carddav.hrefsToRemove", hrefsToRemove); + log.debug(`Stored list of cards to remove: ${hrefsToRemove}`); + } + } + + get propertiesChromeURI() { + return "chrome://messenger/content/addressbook/abCardDAVProperties.xhtml"; + } + get dirType() { + return Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE; + } + get supportsMailingLists() { + return false; + } + + modifyCard(card) { + // Well this is awkward. Because it's defined in nsIAbDirectory, + // modifyCard must not be async, but we need to do async operations. + let newCard = super.modifyCard(card); + this._modifyCard(newCard); + } + async _modifyCard(card) { + try { + await this._sendCardToServer(card); + } catch (ex) { + console.error(ex); + } + } + deleteCards(cards) { + super.deleteCards(cards); + this._deleteCards(cards); + } + async _deleteCards(cards) { + for (let card of cards) { + try { + await this._deleteCardFromServer(card); + } catch (ex) { + console.error(ex); + break; + } + } + + for (let card of cards) { + this._uidsToSync.delete(card.UID); + } + } + dropCard(card, needToCopyCard) { + // Ideally, we'd not add the card until it was on the server, but we have + // to return newCard synchronously. + let newCard = super.dropCard(card, needToCopyCard); + this._sendCardToServer(newCard).catch(console.error); + return newCard; + } + addMailList() { + throw Components.Exception( + "CardDAVDirectory does not implement addMailList", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + setIntValue(name, value) { + super.setIntValue(name, value); + + // Capture changes to the sync interval from the UI. + if (name == "carddav.syncinterval") { + this._scheduleNextSync(); + } + } + + /** CardDAV specific */ + _syncInProgress = false; + _syncTimer = null; + + get _serverURL() { + return this.getStringValue("carddav.url", ""); + } + get _syncToken() { + return this.getStringValue("carddav.token", ""); + } + set _syncToken(value) { + this.setStringValue("carddav.token", value); + } + + /** + * Wraps CardDAVUtils.makeRequest, resolving path this directory's server + * URL, and providing a mechanism to give a username and password specific + * to this directory. + * + * @param {string} path - A path relative to the server URL. + * @param {object} details - See CardDAVUtils.makeRequest. + * @returns {Promise<object>} - See CardDAVUtils.makeRequest. + */ + async _makeRequest(path, details = {}) { + let serverURI = Services.io.newURI(this._serverURL); + let uri = serverURI.resolve(path); + + if (!("_oAuth" in this)) { + if (lazy.OAuth2Providers.getHostnameDetails(serverURI.host)) { + this._oAuth = new lazy.OAuth2Module(); + this._oAuth.initFromABDirectory(this, serverURI.host); + } else { + this._oAuth = null; + } + } + details.oAuth = this._oAuth; + + let username = this.getStringValue("carddav.username", ""); + let callbacks = new lazy.NotificationCallbacks(username); + details.callbacks = callbacks; + + details.userContextId = + this._userContextId ?? lazy.CardDAVUtils.contextForUsername(username); + + let response; + try { + Services.obs.notifyObservers( + this, + "addrbook-directory-request-start", + this.UID + ); + response = await lazy.CardDAVUtils.makeRequest(uri, details); + } finally { + Services.obs.notifyObservers( + this, + "addrbook-directory-request-end", + this.UID + ); + } + if ( + details.expectedStatuses && + !details.expectedStatuses.includes(response.status) + ) { + throw Components.Exception( + `Incorrect response from server: ${response.status} ${response.statusText}`, + Cr.NS_ERROR_FAILURE + ); + } + + if (callbacks.shouldSaveAuth) { + // The user was prompted for a username and password. Save the response. + this.setStringValue("carddav.username", callbacks.authInfo?.username); + callbacks.saveAuth(); + } + return response; + } + + /** + * Gets or creates the path for storing this card on the server. Cards that + * already exist on the server have this value in the _href property. + * + * @param {nsIAbCard} card + * @returns {string} + */ + _getCardHref(card) { + let href = card.getProperty("_href", ""); + if (href) { + return href; + } + href = Services.io.newURI(this._serverURL).resolve(`${card.UID}.vcf`); + return new URL(href).pathname; + } + + _multigetRequest(hrefsToFetch) { + hrefsToFetch = hrefsToFetch.map( + href => ` <d:href>${xmlEncode(href)}</d:href>` + ); + let data = `<card:addressbook-multiget ${NAMESPACE_STRING}> + <d:prop> + <d:getetag/> + <card:address-data/> + </d:prop> + ${hrefsToFetch.join("\n")} + </card:addressbook-multiget>`; + + return this._makeRequest("", { + method: "REPORT", + body: data, + headers: { + Depth: 1, + }, + expectedStatuses: [207], + }); + } + + /** + * Performs a multiget request for the provided hrefs, and adds each response + * to the directory, adding or modifying as necessary. + * + * @param {string[]} hrefsToFetch - The href of each card to be requested. + */ + async _fetchAndStore(hrefsToFetch) { + if (hrefsToFetch.length == 0) { + return; + } + + let response = await this._multigetRequest(hrefsToFetch); + + // If this directory is set to read-only, the following operations would + // throw NS_ERROR_FAILURE, but sync operations are allowed on a read-only + // directory, so set this._overrideReadOnly to avoid the exception. + // + // Do not use await while it is set, and use a try/finally block to ensure + // it is cleared. + + try { + this._overrideReadOnly = true; + for (let { href, properties } of this._readResponse(response.dom)) { + if (!properties) { + continue; + } + + let etag = properties.querySelector("getetag")?.textContent; + let vCard = normalizeLineEndings( + properties.querySelector("address-data")?.textContent + ); + + let abCard = lazy.VCardUtils.vCardToAbCard(vCard); + abCard.setProperty("_etag", etag); + abCard.setProperty("_href", href); + + if (!this.cards.has(abCard.UID)) { + super.dropCard(abCard, false); + } else if (this.loadCardProperties(abCard.UID).get("_etag") != etag) { + super.modifyCard(abCard); + } + } + } finally { + this._overrideReadOnly = false; + } + } + + /** + * Reads a multistatus response, yielding once for each response element. + * + * @param {Document} dom - as returned by CardDAVUtils.makeRequest. + * @yields {object} - An object representing a single <response> element + * from the document: + * - href, the href of the object represented + * - notFound, if a 404 status applies to this response + * - properties, the <prop> element, if any, containing properties + * of the object represented + */ + _readResponse = function* (dom) { + if (!dom || dom.documentElement.localName != "multistatus") { + throw Components.Exception( + `Expected a multistatus response, but didn't get one`, + Cr.NS_ERROR_FAILURE + ); + } + + for (let r of dom.querySelectorAll("response")) { + let response = { + href: r.querySelector("href")?.textContent, + }; + + let responseStatus = r.querySelector("response > status"); + if (responseStatus?.textContent.startsWith("HTTP/1.1 404")) { + response.notFound = true; + yield response; + continue; + } + + for (let p of r.querySelectorAll("response > propstat")) { + let status = p.querySelector("propstat > status").textContent; + if (status == "HTTP/1.1 200 OK") { + response.properties = p.querySelector("propstat > prop"); + } + } + + yield response; + } + }; + + /** + * Converts the card to a vCard and performs a PUT request to store it on the + * server. Then immediately performs a GET request ensuring the local copy + * matches the server copy. Stores the card in the database on success. + * + * @param {nsIAbCard} card + * @returns {boolean} true if the PUT request succeeded without conflict, + * false if there was a conflict. + * @throws if the server responded with anything other than a success or + * conflict status code. + */ + async _sendCardToServer(card) { + let href = this._getCardHref(card); + let requestDetails = { + method: "PUT", + contentType: "text/vcard", + }; + + let vCard = card.getProperty("_vCard", ""); + if (this._isGoogleCardDAV) { + // There must be an `N` property, even if empty. + let vCardProperties = lazy.VCardProperties.fromVCard(vCard); + if (!vCardProperties.getFirstEntry("n")) { + vCardProperties.addValue("n", ["", "", "", "", ""]); + } + requestDetails.body = vCardProperties.toVCard(); + } else { + requestDetails.body = vCard; + } + + let response; + try { + log.debug(`Sending ${href} to server.`); + response = await this._makeRequest(href, requestDetails); + } catch (ex) { + Services.obs.notifyObservers(this, "addrbook-directory-sync-failed"); + this._uidsToSync.add(card.UID); + throw ex; + } + + if (response.status >= 400) { + throw Components.Exception( + `Sending card to the server failed, response was ${response.status} ${response.statusText}`, + Cr.NS_ERROR_FAILURE + ); + } + + // At this point we *should* be able to make a simple GET request and + // store the response. But Google moves the data (fair enough) without + // telling us where it went (c'mon, really?). Fortunately a multiget + // request at the original location works. + + response = await this._multigetRequest([href]); + + for (let { href, properties } of this._readResponse(response.dom)) { + if (!properties) { + continue; + } + + let etag = properties.querySelector("getetag")?.textContent; + let vCard = normalizeLineEndings( + properties.querySelector("address-data")?.textContent + ); + + let abCard = lazy.VCardUtils.vCardToAbCard(vCard); + abCard.setProperty("_etag", etag); + abCard.setProperty("_href", href); + + if (abCard.UID == card.UID) { + super.modifyCard(abCard); + } else { + // Add a property so the UI can work out if it's still displaying the + // old card and respond appropriately. + abCard.setProperty("_originalUID", card.UID); + super.dropCard(abCard, false); + super.deleteCards([card]); + } + } + } + + /** + * Deletes card from the server. + * + * @param {nsIAbCard|string} cardOrHRef + */ + async _deleteCardFromServer(cardOrHRef) { + let href; + if (typeof cardOrHRef == "string") { + href = cardOrHRef; + } else { + href = cardOrHRef.getProperty("_href", ""); + } + if (!href) { + return; + } + + try { + log.debug(`Removing ${href} from server.`); + await this._makeRequest(href, { method: "DELETE" }); + } catch (ex) { + Services.obs.notifyObservers(this, "addrbook-directory-sync-failed"); + this._hrefsToRemove.add(href); + throw ex; + } + } + + /** + * Set up a repeating timer for synchronisation with the server. The timer's + * interval is defined by pref, set it to 0 to disable sync'ing altogether. + */ + _scheduleNextSync() { + if (this._syncTimer) { + lazy.clearInterval(this._syncTimer); + this._syncTimer = null; + } + + let interval = this.getIntValue("carddav.syncinterval", 30); + if (interval <= 0) { + return; + } + + this._syncTimer = lazy.setInterval( + () => this.syncWithServer(false), + interval * 60000 + ); + } + + /** + * Get all cards on the server and add them to this directory. + * + * This is usually used for the initial population of a directory, but it + * can also be used for a complete re-sync. + */ + async fetchAllFromServer() { + log.log("Fetching all cards from the server."); + this._syncInProgress = true; + + let data = `<propfind xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}> + <prop> + <resourcetype/> + <getetag/> + <cs:getctag/> + </prop> + </propfind>`; + + let response = await this._makeRequest("", { + method: "PROPFIND", + body: data, + headers: { + Depth: 1, + }, + expectedStatuses: [207], + }); + + // A map of all existing hrefs and etags. If the etag for an href matches + // what we already have, we won't fetch it. + let currentHrefs = new Map( + Array.from(this.cards.values(), c => [c.get("_href"), c.get("_etag")]) + ); + + let hrefsToFetch = []; + for (let { href, properties } of this._readResponse(response.dom)) { + if (!properties || properties.querySelector("resourcetype collection")) { + continue; + } + + let currentEtag = currentHrefs.get(href); + currentHrefs.delete(href); + + let etag = properties.querySelector("getetag")?.textContent; + if (etag && currentEtag == etag) { + continue; + } + + hrefsToFetch.push(href); + } + + // Delete any existing cards we didn't see. They're not on the server so + // they shouldn't be on the client. + let cardsToDelete = []; + for (let href of currentHrefs.keys()) { + cardsToDelete.push(this.getCardFromProperty("_href", href, true)); + } + if (cardsToDelete.length > 0) { + super.deleteCards(cardsToDelete); + } + + // Fetch any cards we don't already have, or that have changed. + if (hrefsToFetch.length > 0) { + response = await this._multigetRequest(hrefsToFetch); + + let abCards = []; + + for (let { href, properties } of this._readResponse(response.dom)) { + if (!properties) { + continue; + } + + let etag = properties.querySelector("getetag")?.textContent; + let vCard = normalizeLineEndings( + properties.querySelector("address-data")?.textContent + ); + + try { + let abCard = lazy.VCardUtils.vCardToAbCard(vCard); + abCard.setProperty("_etag", etag); + abCard.setProperty("_href", href); + abCards.push(abCard); + } catch (ex) { + log.error(`Error parsing: ${vCard}`); + console.error(ex); + } + } + + await this.bulkAddCards(abCards); + } + + await this._getSyncToken(); + + log.log("Sync with server completed successfully."); + Services.obs.notifyObservers(this, "addrbook-directory-synced"); + + this._scheduleNextSync(); + this._syncInProgress = false; + } + + /** + * Begin a sync operation. This function will decide which sync protocol to + * use based on the directory's configuration. It will also (re)start the + * timer for the next synchronisation unless told not to. + * + * @param {boolean} shouldResetTimer + */ + async syncWithServer(shouldResetTimer = true) { + if (this._syncInProgress || !this._serverURL) { + return; + } + + log.log("Performing sync with server."); + this._syncInProgress = true; + + try { + // First perform all pending removals. We don't want to have deleted cards + // reappearing when we sync. + for (let href of this._hrefsToRemove) { + await this._deleteCardFromServer(href); + } + this._hrefsToRemove.clear(); + + // Now update any cards that were modified while not connected to the server. + for (let uid of this._uidsToSync) { + let card = this.getCard(uid); + // The card may no longer exist. It shouldn't still be listed to send, + // but it might be. + if (card) { + await this._sendCardToServer(card); + } + } + this._uidsToSync.clear(); + + if (this._syncToken) { + await this.updateAllFromServerV2(); + } else { + await this.updateAllFromServerV1(); + } + } catch (ex) { + log.error("Sync with server failed."); + throw ex; + } finally { + if (shouldResetTimer) { + this._scheduleNextSync(); + } + this._syncInProgress = false; + } + } + + /** + * Compares cards in the directory with cards on the server, and updates the + * directory to match what is on the server. + */ + async updateAllFromServerV1() { + let data = `<propfind xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}> + <prop> + <resourcetype/> + <getetag/> + <cs:getctag/> + </prop> + </propfind>`; + + let response = await this._makeRequest("", { + method: "PROPFIND", + body: data, + headers: { + Depth: 1, + }, + expectedStatuses: [207], + }); + + let hrefMap = new Map(); + for (let { href, properties } of this._readResponse(response.dom)) { + if ( + !properties || + !properties.querySelector("resourcetype") || + properties.querySelector("resourcetype collection") + ) { + continue; + } + + let etag = properties.querySelector("getetag").textContent; + hrefMap.set(href, etag); + } + + let cardMap = new Map(); + let hrefsToFetch = []; + let cardsToDelete = []; + for (let card of this.childCards) { + let href = card.getProperty("_href", ""); + let etag = card.getProperty("_etag", ""); + + if (!href || !etag) { + // Not sure how we got here. Ignore it. + continue; + } + cardMap.set(href, card); + if (hrefMap.has(href)) { + if (hrefMap.get(href) != etag) { + // The card was updated on server. + hrefsToFetch.push(href); + } + } else { + // The card doesn't exist on the server. + cardsToDelete.push(card); + } + } + + for (let href of hrefMap.keys()) { + if (!cardMap.has(href)) { + // The card is new on the server. + hrefsToFetch.push(href); + } + } + + // If this directory is set to read-only, the following operations would + // throw NS_ERROR_FAILURE, but sync operations are allowed on a read-only + // directory, so set this._overrideReadOnly to avoid the exception. + // + // Do not use await while it is set, and use a try/finally block to ensure + // it is cleared. + + if (cardsToDelete.length > 0) { + this._overrideReadOnly = true; + try { + super.deleteCards(cardsToDelete); + } finally { + this._overrideReadOnly = false; + } + } + + await this._fetchAndStore(hrefsToFetch); + + log.log("Sync with server completed successfully."); + Services.obs.notifyObservers(this, "addrbook-directory-synced"); + } + + /** + * Retrieves the current sync token from the server. + * + * @see RFC 6578 + */ + async _getSyncToken() { + log.log("Fetching new sync token"); + + let data = `<propfind xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}> + <prop> + <displayname/> + <cs:getctag/> + <sync-token/> + </prop> + </propfind>`; + + let response = await this._makeRequest("", { + method: "PROPFIND", + body: data, + headers: { + Depth: 0, + }, + }); + + if (response.status == 207) { + for (let { properties } of this._readResponse(response.dom)) { + let token = properties?.querySelector("prop sync-token"); + if (token) { + this._syncToken = token.textContent; + return; + } + } + } + + this._syncToken = ""; + } + + /** + * Gets a list of changes on the server since the last call to getSyncToken + * or updateAllFromServerV2, and updates the directory to match what is on + * the server. + * + * @see RFC 6578 + */ + async updateAllFromServerV2() { + let syncToken = this._syncToken; + if (!syncToken) { + throw new Components.Exception("No sync token", Cr.NS_ERROR_UNEXPECTED); + } + + let data = `<sync-collection xmlns="${ + PREFIX_BINDINGS.d + }" ${NAMESPACE_STRING}> + <sync-token>${xmlEncode(syncToken)}</sync-token> + <sync-level>1</sync-level> + <prop> + <getetag/> + <card:address-data/> + </prop> + </sync-collection>`; + + let response = await this._makeRequest("", { + method: "REPORT", + body: data, + headers: { + Depth: 1, // Only Google seems to need this. + }, + expectedStatuses: [207, 400], + }); + + if (response.status == 400) { + log.warn( + `Server responded with: ${response.status} ${response.statusText}` + ); + await this.fetchAllFromServer(); + return; + } + + let dom = response.dom; + + // If this directory is set to read-only, the following operations would + // throw NS_ERROR_FAILURE, but sync operations are allowed on a read-only + // directory, so set this._overrideReadOnly to avoid the exception. + // + // Do not use await while it is set, and use a try/finally block to ensure + // it is cleared. + + let hrefsToFetch = []; + try { + this._overrideReadOnly = true; + let cardsToDelete = []; + for (let { href, notFound, properties } of this._readResponse(dom)) { + let card = this.getCardFromProperty("_href", href, true); + if (notFound) { + if (card) { + cardsToDelete.push(card); + } + continue; + } + if (!properties) { + continue; + } + + let etag = properties.querySelector("getetag")?.textContent; + if (!etag) { + continue; + } + let vCard = properties.querySelector("address-data")?.textContent; + if (!vCard) { + hrefsToFetch.push(href); + continue; + } + vCard = normalizeLineEndings(vCard); + + let abCard = lazy.VCardUtils.vCardToAbCard(vCard); + abCard.setProperty("_etag", etag); + abCard.setProperty("_href", href); + + if (card) { + if (card.getProperty("_etag", "") != etag) { + super.modifyCard(abCard); + } + } else { + super.dropCard(abCard, false); + } + } + + if (cardsToDelete.length > 0) { + super.deleteCards(cardsToDelete); + } + } finally { + this._overrideReadOnly = false; + } + + await this._fetchAndStore(hrefsToFetch); + + this._syncToken = dom.querySelector("sync-token").textContent; + + log.log("Sync with server completed successfully."); + Services.obs.notifyObservers(this, "addrbook-directory-synced"); + } + + static forFile(fileName) { + let directory = super.forFile(fileName); + if (directory instanceof CardDAVDirectory) { + return directory; + } + return undefined; + } +} +CardDAVDirectory.prototype.classID = Components.ID( + "{1fa9941a-07d5-4a6f-9673-15327fc2b9ab}" +); + +/** + * Ensure that `string` always has Windows line-endings. Some functions, + * notably DOMParser.parseFromString, strip \r, but we want it because \r\n + * is a part of the vCard specification. + */ +function normalizeLineEndings(string) { + if (string.includes("\r\n")) { + return string; + } + return string.replace(/\n/g, "\r\n"); +} + +/** + * Encode special characters safely for XML. + */ +function xmlEncode(string) { + return string + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/</g, "<") + .replace(/>/g, ">"); +} diff --git a/comm/mailnews/addrbook/modules/CardDAVUtils.jsm b/comm/mailnews/addrbook/modules/CardDAVUtils.jsm new file mode 100644 index 0000000000..d45b5a9b42 --- /dev/null +++ b/comm/mailnews/addrbook/modules/CardDAVUtils.jsm @@ -0,0 +1,718 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["CardDAVUtils", "NotificationCallbacks"]; + +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +const { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm"); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + CardDAVDirectory: "resource:///modules/CardDAVDirectory.jsm", + MsgAuthPrompt: "resource:///modules/MsgAsyncPrompter.jsm", + OAuth2: "resource:///modules/OAuth2.jsm", + OAuth2Providers: "resource:///modules/OAuth2Providers.jsm", +}); +XPCOMUtils.defineLazyServiceGetter( + lazy, + "nssErrorsService", + "@mozilla.org/nss_errors_service;1", + "nsINSSErrorsService" +); + +// Use presets only where DNS discovery fails. Set to null to prevent +// auto-fill completely for a domain. +const PRESETS = { + // For testing purposes. + "bad.invalid": null, + // Google responds correctly but the provided address returns 404. + "gmail.com": "https://www.googleapis.com", + "googlemail.com": "https://www.googleapis.com", + // For testing purposes. + "test.invalid": "http://localhost:9999", + // Yahoo! OAuth is not working yet. + "yahoo.com": null, +}; + +// At least one of these ACL privileges must be present to consider an address +// book writable. +const writePrivs = ["write", "write-properties", "write-content", "all"]; + +// At least one of these ACL privileges must be present to consider an address +// book readable. +const readPrivs = ["read", "all"]; + +var CardDAVUtils = { + _contextMap: new Map(), + + /** + * Returns the id of a unique private context for each username. When the + * userContextId is set on a principal, this allows the use of multiple + * usernames on the same server without the networking code causing issues. + * + * @param {string} username + * @returns {integer} + */ + contextForUsername(username) { + if (username && CardDAVUtils._contextMap.has(username)) { + return CardDAVUtils._contextMap.get(username); + } + + // This could be any 32-bit integer, as long as it isn't already in use. + let nextId = 25000 + CardDAVUtils._contextMap.size; + lazy.ContextualIdentityService.remove(nextId); + CardDAVUtils._contextMap.set(username, nextId); + return nextId; + }, + + /** + * Make an HTTP request. If the request needs a username and password, the + * given authPrompt is called. + * + * @param {string} uri + * @param {object} details + * @param {string} [details.method] + * @param {object} [details.headers] + * @param {string} [details.body] + * @param {string} [details.contentType] + * @param {msgIOAuth2Module} [details.oAuth] - If this is present the + * request will use OAuth2 authorization. + * @param {NotificationCallbacks} [details.callbacks] - Handles usernames + * and passwords for this request. + * @param {integer} [details.userContextId] - See _contextForUsername. + * + * @returns {Promise<object>} - Resolves to an object with getters for: + * - status, the HTTP response code + * - statusText, the HTTP response message + * - text, the returned data as a String + * - dom, the returned data parsed into a Document + */ + async makeRequest(uri, details) { + if (typeof uri == "string") { + uri = Services.io.newURI(uri); + } + let { + method = "GET", + headers = {}, + body = null, + contentType = "text/xml", + oAuth = null, + callbacks = new NotificationCallbacks(), + userContextId = Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID, + } = details; + headers["Content-Type"] = contentType; + if (oAuth) { + headers.Authorization = await new Promise((resolve, reject) => { + oAuth.connect(true, { + onSuccess(token) { + resolve( + // `token` is a base64-encoded string for SASL XOAUTH2. That is + // not what we want, extract just the Bearer token part. + // (See OAuth2Module.connect.) + atob(token).split("\x01")[1].slice(5) + ); + }, + onFailure: reject, + }); + }); + } + + return new Promise((resolve, reject) => { + let principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + { userContextId } + ); + + let channel = Services.io.newChannelFromURI( + uri, + null, + principal, + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + channel.QueryInterface(Ci.nsIHttpChannel); + for (let [name, value] of Object.entries(headers)) { + channel.setRequestHeader(name, value, false); + } + if (body !== null) { + let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + stream.setUTF8Data(body, body.length); + + channel.QueryInterface(Ci.nsIUploadChannel); + channel.setUploadStream(stream, contentType, -1); + } + channel.requestMethod = method; // Must go after setUploadStream. + channel.notificationCallbacks = callbacks; + + let listener = Cc["@mozilla.org/network/stream-loader;1"].createInstance( + Ci.nsIStreamLoader + ); + listener.init({ + onStreamComplete(loader, context, status, resultLength, result) { + let finalChannel = loader.request.QueryInterface(Ci.nsIHttpChannel); + if (!Components.isSuccessCode(status)) { + let isCertError = false; + try { + let errorType = lazy.nssErrorsService.getErrorClass(status); + if (errorType == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) { + isCertError = true; + } + } catch (ex) { + // nsINSSErrorsService.getErrorClass throws if given a non-TLS, + // non-cert error, so ignore this. + } + + if (isCertError && finalChannel.securityInfo) { + let secInfo = finalChannel.securityInfo.QueryInterface( + Ci.nsITransportSecurityInfo + ); + let params = { + exceptionAdded: false, + securityInfo: secInfo, + prefetchCert: true, + location: finalChannel.originalURI.displayHostPort, + }; + Services.wm + .getMostRecentWindow("") + .openDialog( + "chrome://pippki/content/exceptionDialog.xhtml", + "", + "chrome,centerscreen,modal", + params + ); + + if (params.exceptionAdded) { + // Try again now that an exception has been added. + CardDAVUtils.makeRequest(uri, details).then(resolve, reject); + return; + } + } + + reject(new Components.Exception("Connection failure", status)); + return; + } + if (finalChannel.responseStatus == 401) { + // We tried to authenticate, but failed. + reject( + new Components.Exception( + "Authorization failure", + Cr.NS_ERROR_FAILURE + ) + ); + return; + } + resolve({ + get status() { + return finalChannel.responseStatus; + }, + get statusText() { + return finalChannel.responseStatusText; + }, + get text() { + return new TextDecoder().decode(Uint8Array.from(result)); + }, + get dom() { + if (this._dom === undefined) { + try { + this._dom = new DOMParser().parseFromString( + this.text, + "text/xml" + ); + } catch (ex) { + this._dom = null; + } + } + return this._dom; + }, + }); + }, + }); + channel.asyncOpen(listener, channel); + }); + }, + + /** + * @typedef foundBook + * @property {URL} url - The address for this address book. + * @param {string} name - The name of this address book on the server. + * @param {Function} create - A callback to add this address book locally. + */ + + /** + * Uses DNS look-ups and magic URLs to detects CardDAV address books. + * + * @param {string} username - Username for the server at `location`. + * @param {string} [password] - If not given, the user will be prompted. + * @param {string} location - The URL of a server to query. + * @param {boolean} [forcePrompt=false] - If true, the user will be shown a + * login prompt even if `password` is specified. If false, the user will + * be shown a prompt only if `password` is not specified and no saved + * password matches `username` and `location`. + * @returns {foundBook[]} - An array of found address books. + */ + async detectAddressBooks(username, password, location, forcePrompt = false) { + let log = console.createInstance({ + prefix: "carddav.setup", + maxLogLevel: "Warn", + maxLogLevelPref: "carddav.setup.loglevel", + }); + + // Use a unique context for each attempt, so a prompt is always shown. + let userContextId = Math.floor(Date.now() / 1000); + + let url = new URL(location); + + if (url.hostname in PRESETS) { + if (PRESETS[url.hostname] === null) { + throw new Components.Exception( + `${url} is known to be incompatible`, + Cr.NS_ERROR_NOT_AVAILABLE + ); + } + log.log(`Using preset URL for ${url}`); + url = new URL(PRESETS[url.hostname]); + } + + if (url.pathname == "/" && !(url.hostname in PRESETS)) { + log.log(`Looking up DNS record for ${url.hostname}`); + let domain = `_carddavs._tcp.${url.hostname}`; + let srvRecords = await DNS.srv(domain); + srvRecords.sort((a, b) => a.prio - b.prio || b.weight - a.weight); + + if (srvRecords[0]) { + url = new URL(`https://${srvRecords[0].host}:${srvRecords[0].port}`); + log.log(`Found a DNS SRV record pointing to ${url.host}`); + + let txtRecords = await DNS.txt(domain); + txtRecords.sort((a, b) => a.prio - b.prio || b.weight - a.weight); + txtRecords = txtRecords.filter(result => + result.data.startsWith("path=") + ); + + if (txtRecords[0]) { + url.pathname = txtRecords[0].data.substr(5); + log.log(`Found a DNS TXT record pointing to ${url.href}`); + } + } else { + let mxRecords = await DNS.mx(url.hostname); + if (mxRecords.some(r => /\bgoogle\.com$/.test(r.host))) { + log.log( + `Found a DNS MX record for Google, using preset URL for ${url}` + ); + url = new URL(PRESETS["gmail.com"]); + } + } + } + + let oAuth = null; + let callbacks = new NotificationCallbacks(username, password, forcePrompt); + + let requestParams = { + method: "PROPFIND", + callbacks, + userContextId, + headers: { + Depth: 0, + }, + body: `<propfind xmlns="DAV:"> + <prop> + <resourcetype/> + <displayname/> + <current-user-principal/> + <current-user-privilege-set/> + </prop> + </propfind>`, + }; + + let details = lazy.OAuth2Providers.getHostnameDetails(url.host); + if (details) { + let [issuer, scope] = details; + let issuerDetails = lazy.OAuth2Providers.getIssuerDetails(issuer); + + oAuth = new lazy.OAuth2(scope, issuerDetails); + oAuth._isNew = true; + oAuth._loginOrigin = `oauth://${issuer}`; + oAuth._scope = scope; + for (let login of Services.logins.findLogins( + oAuth._loginOrigin, + null, + "" + )) { + if ( + login.username == username && + (login.httpRealm == scope || + login.httpRealm.split(" ").includes(scope)) + ) { + oAuth.refreshToken = login.password; + oAuth._isNew = false; + break; + } + } + + if (username) { + oAuth.extraAuthParams = [["login_hint", username]]; + } + + // Implement msgIOAuth2Module.connect, which CardDAVUtils.makeRequest expects. + requestParams.oAuth = { + QueryInterface: ChromeUtils.generateQI(["msgIOAuth2Module"]), + connect(withUI, listener) { + oAuth.connect( + () => + listener.onSuccess( + // String format based on what OAuth2Module has. + btoa(`\x01auth=Bearer ${oAuth.accessToken}`) + ), + () => listener.onFailure(Cr.NS_ERROR_ABORT), + withUI, + false + ); + }, + }; + } + + let response; + let triedURLs = new Set(); + async function tryURL(url) { + if (triedURLs.has(url)) { + return; + } + triedURLs.add(url); + + log.log(`Attempting to connect to ${url}`); + response = await CardDAVUtils.makeRequest(url, requestParams); + if (response.status == 207 && response.dom) { + log.log(`${url} ... success`); + } else { + log.log( + `${url} ... response was "${response.status} ${response.statusText}"` + ); + response = null; + } + } + + if (url.pathname != "/") { + // This might be the full URL of an address book. + await tryURL(url.href); + if ( + !response?.dom?.querySelector("resourcetype addressbook") && + !response?.dom?.querySelector("current-user-principal href") + ) { + response = null; + } + } + if (!response || !response.dom) { + // Auto-discovery using a magic URL. + requestParams.body = `<propfind xmlns="DAV:"> + <prop> + <current-user-principal/> + </prop> + </propfind>`; + await tryURL(`${url.origin}/.well-known/carddav`); + } + if (!response) { + // Auto-discovery at the root of the domain. + await tryURL(`${url.origin}/`); + } + if (!response) { + // We've run out of ideas. + throw new Components.Exception( + "Address book discovery failed", + Cr.NS_ERROR_FAILURE + ); + } + + if (!response.dom.querySelector("resourcetype addressbook")) { + let userPrincipal = response.dom.querySelector( + "current-user-principal href" + ); + if (!userPrincipal) { + // We've run out of ideas. + throw new Components.Exception( + "Address book discovery failed", + Cr.NS_ERROR_FAILURE + ); + } + // Steps two and three of auto-discovery. If the entered URL did point + // to an address book, we won't get here. + url = new URL(userPrincipal.textContent, url); + requestParams.body = `<propfind xmlns="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"> + <prop> + <card:addressbook-home-set/> + </prop> + </propfind>`; + await tryURL(url.href); + + url = new URL( + response.dom.querySelector("addressbook-home-set href").textContent, + url + ); + requestParams.headers.Depth = 1; + requestParams.body = `<propfind xmlns="DAV:"> + <prop> + <resourcetype/> + <displayname/> + <current-user-privilege-set/> + </prop> + </propfind>`; + await tryURL(url.href); + } + + // Find any directories in the response. + + let foundBooks = []; + for (let r of response.dom.querySelectorAll("response")) { + if (r.querySelector("status")?.textContent != "HTTP/1.1 200 OK") { + continue; + } + if (!r.querySelector("resourcetype addressbook")) { + continue; + } + + // If the server provided ACL information, skip address books that we do + // not have read privileges to. + let privNode = r.querySelector("current-user-privilege-set"); + let isWritable = false; + let isReadable = false; + if (privNode) { + let privs = Array.from(privNode.querySelectorAll("privilege > *")).map( + node => node.localName + ); + + isWritable = writePrivs.some(priv => privs.includes(priv)); + isReadable = readPrivs.some(priv => privs.includes(priv)); + + if (!isWritable && !isReadable) { + continue; + } + } + + url = new URL(r.querySelector("href").textContent, url); + let name = r.querySelector("displayname")?.textContent; + if (!name) { + // The server didn't give a name, let's make one from the path. + name = url.pathname.replace(/\/$/, "").split("/").slice(-1)[0]; + } + if (!name) { + // That didn't work either, use the hostname. + name = url.hostname; + } + foundBooks.push({ + url, + name, + create() { + let dirPrefId = MailServices.ab.newAddressBook( + this.name, + null, + Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE, + null + ); + let book = MailServices.ab.getDirectoryFromId(dirPrefId); + book.setStringValue("carddav.url", this.url); + + if (!isWritable && isReadable) { + book.setBoolValue("readOnly", true); + } + + if (oAuth) { + if (oAuth._isNew) { + log.log(`Saving refresh token for ${username}`); + let newLoginInfo = Cc[ + "@mozilla.org/login-manager/loginInfo;1" + ].createInstance(Ci.nsILoginInfo); + newLoginInfo.init( + oAuth._loginOrigin, + null, + oAuth._scope, + username, + oAuth.refreshToken, + "", + "" + ); + try { + Services.logins.addLogin(newLoginInfo); + } catch (ex) { + console.error(ex); + } + oAuth._isNew = false; + } + book.setStringValue("carddav.username", username); + } else if (callbacks.authInfo?.username) { + log.log(`Saving login info for ${callbacks.authInfo.username}`); + book.setStringValue( + "carddav.username", + callbacks.authInfo.username + ); + callbacks.saveAuth(); + } + + let dir = lazy.CardDAVDirectory.forFile(book.fileName); + // Pass the context to the created address book. This prevents asking + // for a username/password again in the case that we didn't save it. + // The user won't be prompted again until Thunderbird is restarted. + dir._userContextId = userContextId; + dir.fetchAllFromServer(); + + return dir; + }, + }); + } + return foundBooks; + }, +}; + +/** + * Passed to nsIChannel.notificationCallbacks in CardDAVDirectory.makeRequest. + * This handles HTTP authentication, prompting the user if necessary. It also + * ensures important headers are copied from one channel to another if a + * redirection occurs. + * + * @implements {nsIInterfaceRequestor} + * @implements {nsIAuthPrompt2} + * @implements {nsIChannelEventSink} + */ +class NotificationCallbacks { + /** + * @param {string} [username] - Used to pre-fill any auth dialogs. + * @param {string} [password] - Used to pre-fill any auth dialogs. + * @param {boolean} [forcePrompt] - Skips checking the password manager for + * a password, even if username is given. The user will be prompted. + */ + constructor(username, password, forcePrompt) { + this.username = username; + this.password = password; + this.forcePrompt = forcePrompt; + } + QueryInterface = ChromeUtils.generateQI([ + "nsIInterfaceRequestor", + "nsIAuthPrompt2", + "nsIChannelEventSink", + ]); + getInterface = ChromeUtils.generateQI([ + "nsIAuthPrompt2", + "nsIChannelEventSink", + ]); + promptAuth(channel, level, authInfo) { + if (authInfo.flags & Ci.nsIAuthInformation.PREVIOUS_FAILED) { + return false; + } + + this.origin = channel.URI.prePath; + this.authInfo = authInfo; + + if (!this.forcePrompt) { + if (this.username && this.password) { + authInfo.username = this.username; + authInfo.password = this.password; + this.shouldSaveAuth = true; + return true; + } + + let logins = Services.logins.findLogins(channel.URI.prePath, null, ""); + for (let l of logins) { + if (l.username == this.username) { + authInfo.username = l.username; + authInfo.password = l.password; + return true; + } + } + } + + authInfo.username = this.username; + authInfo.password = this.password; + + let savePasswordLabel = null; + let savePassword = {}; + if (Services.prefs.getBoolPref("signon.rememberSignons", true)) { + savePasswordLabel = Services.strings + .createBundle("chrome://passwordmgr/locale/passwordmgr.properties") + .GetStringFromName("rememberPassword"); + savePassword.value = true; + } + + let returnValue = new lazy.MsgAuthPrompt().promptAuth( + channel, + level, + authInfo, + savePasswordLabel, + savePassword + ); + if (returnValue) { + this.shouldSaveAuth = savePassword.value; + } + return returnValue; + } + saveAuth() { + if (this.shouldSaveAuth) { + let newLoginInfo = Cc[ + "@mozilla.org/login-manager/loginInfo;1" + ].createInstance(Ci.nsILoginInfo); + newLoginInfo.init( + this.origin, + null, + this.authInfo.realm, + this.authInfo.username, + this.authInfo.password, + "", + "" + ); + try { + Services.logins.addLogin(newLoginInfo); + } catch (ex) { + console.error(ex); + } + } + } + asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) { + /** + * Copy the given header from the old channel to the new one, ignoring missing headers + * + * @param {string} header - The header to copy + */ + function copyHeader(header) { + try { + let headerValue = oldChannel.getRequestHeader(header); + if (headerValue) { + newChannel.setRequestHeader(header, headerValue, false); + } + } catch (e) { + if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) { + // The header could possibly not be available, ignore that + // case but throw otherwise + throw e; + } + } + } + + // Make sure we can get/set headers on both channels. + newChannel.QueryInterface(Ci.nsIHttpChannel); + oldChannel.QueryInterface(Ci.nsIHttpChannel); + + // If any other header is used, it should be added here. We might want + // to just copy all headers over to the new channel. + copyHeader("Authorization"); + copyHeader("Depth"); + copyHeader("Originator"); + copyHeader("Recipient"); + copyHeader("If-None-Match"); + copyHeader("If-Match"); + + newChannel.requestMethod = oldChannel.requestMethod; + callback.onRedirectVerifyCallback(Cr.NS_OK); + } +} diff --git a/comm/mailnews/addrbook/modules/LDAPClient.jsm b/comm/mailnews/addrbook/modules/LDAPClient.jsm new file mode 100644 index 0000000000..e26b7b5fce --- /dev/null +++ b/comm/mailnews/addrbook/modules/LDAPClient.jsm @@ -0,0 +1,285 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["LDAPClient"]; + +var { CommonUtils } = ChromeUtils.importESModule( + "resource://services-common/utils.sys.mjs" +); +var { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); +var { + AbandonRequest, + BindRequest, + UnbindRequest, + SearchRequest, + LDAPResponse, +} = ChromeUtils.import("resource:///modules/LDAPMessage.jsm"); + +class LDAPClient { + /** + * @param {string} host - The LDAP server host. + * @param {number} port - The LDAP server port. + * @param {boolean} useSecureTransport - Whether to use TLS connection. + */ + constructor(host, port, useSecureTransport) { + this.onOpen = () => {}; + this.onError = () => {}; + + this._host = host; + this._port = port; + this._useSecureTransport = useSecureTransport; + + this._messageId = 1; + this._callbackMap = new Map(); + + this._logger = console.createInstance({ + prefix: "mailnews.ldap", + maxLogLevel: "Warn", + maxLogLevelPref: "mailnews.ldap.loglevel", + }); + + this._dataEventsQueue = []; + } + + connect() { + let hostname = this._host.toLowerCase(); + this._logger.debug( + `Connecting to ${ + this._useSecureTransport ? "ldaps" : "ldap" + }://${hostname}:${this._port}` + ); + this._socket = new TCPSocket(hostname, this._port, { + binaryType: "arraybuffer", + useSecureTransport: this._useSecureTransport, + }); + this._socket.onopen = this._onOpen; + this._socket.onerror = this._onError; + } + + /** + * Send a simple bind request to the server. + * + * @param {string} dn - The name to bind. + * @param {string} password - The password. + * @param {Function} callback - Callback function when receiving BindResponse. + * @returns {number} The id of the sent request. + */ + bind(dn, password, callback) { + this._logger.debug(`Binding ${dn}`); + let req = new BindRequest(dn || "", password || ""); + return this._send(req, callback); + } + + /** + * Send a SASL bind request to the server. + * + * @param {string} service - The service host name to bind. + * @param {string} mechanism - The SASL mechanism to use, e.g. GSSAPI. + * @param {string} authModuleType - The auth module type, @see nsIMailAuthModule. + * @param {ArrayBuffer} serverCredentials - The challenge token returned from + * the server, which must be used to generate a new request token. Or + * undefined for the first request. + * @param {Function} callback - Callback function when receiving BindResponse. + * @returns {number} The id of the sent request. + */ + saslBind(service, mechanism, authModuleType, serverCredentials, callback) { + this._logger.debug(`Binding ${service} using ${mechanism}`); + if (!this._authModule || this._authModuleType != authModuleType) { + this._authModuleType = authModuleType; + this._authModule = Cc["@mozilla.org/mail/auth-module;1"].createInstance( + Ci.nsIMailAuthModule + ); + this._authModule.init( + authModuleType, + service, + 0, // nsIAuthModule::REQ_DEFAULT + null, // domain + null, // username + null // password + ); + } + // getNextToken expects a base64 string. + let token = this._authModule.getNextToken( + serverCredentials + ? btoa( + CommonUtils.arrayBufferToByteString( + new Uint8Array(serverCredentials) + ) + ) + : "" + ); + // token is a base64 string, convert it to Uint8Array. + let credentials = CommonUtils.byteStringToArrayBuffer(atob(token)); + let req = new BindRequest("", "", { mechanism, credentials }); + return this._send(req, callback); + } + + /** + * Send an unbind request to the server. + */ + unbind() { + return this._send(new UnbindRequest(), () => this._socket.close()); + } + + /** + * Send a search request to the server. + * + * @param {string} dn - The name to search. + * @param {number} scope - The scope to search. + * @param {string} filter - The filter string. + * @param {string} attributes - Attributes to include in the search result. + * @param {number} timeout - The seconds to wait. + * @param {number} limit - Maximum number of entries to return. + * @param {Function} callback - Callback function when receiving search responses. + * @returns {number} The id of the sent request. + */ + search(dn, scope, filter, attributes, timeout, limit, callback) { + this._logger.debug(`Searching dn="${dn}" filter="${filter}"`); + let req = new SearchRequest(dn, scope, filter, attributes, timeout, limit); + return this._send(req, callback); + } + + /** + * Send an abandon request to the server. + * + * @param {number} messageId - The id of the message to abandon. + */ + abandon(messageId) { + this._logger.debug(`Abandoning ${messageId}`); + this._callbackMap.delete(messageId); + let req = new AbandonRequest(messageId); + this._send(req); + } + + /** + * The open event handler. + */ + _onOpen = () => { + this._logger.debug("Connected"); + this._socket.ondata = this._onData; + this._socket.onclose = this._onClose; + this.onOpen(); + }; + + /** + * The data event handler. Server may send multiple data events after a + * search, we want to handle them asynchonosly and in sequence. + * + * @param {TCPSocketEvent} event - The data event. + */ + _onData = async event => { + if (this._processingData) { + this._dataEventsQueue.push(event); + return; + } + this._processingData = true; + let data = event.data; + if (this._buffer) { + // Concatenate left over data from the last event with the new data. + let arr = new Uint8Array(this._buffer.byteLength + data.byteLength); + arr.set(new Uint8Array(this._buffer)); + arr.set(new Uint8Array(data), this._buffer.byteLength); + data = arr.buffer; + this._buffer = null; + } + let i = 0; + // The payload can contain multiple messages, parse it to the end. + while (data.byteLength) { + i++; + let res; + try { + res = LDAPResponse.fromBER(data); + if (typeof res == "number") { + data = data.slice(res); + continue; + } + } catch (e) { + if (e.result == Cr.NS_ERROR_CANNOT_CONVERT_DATA) { + // The remaining data doesn't form a valid LDAP message, save it for + // the next round. + this._buffer = data; + this._handleNextDataEvent(); + return; + } + throw e; + } + this._logger.debug( + `S: [${res.messageId}] ${res.constructor.name}`, + res.result.resultCode >= 0 + ? `resultCode=${res.result.resultCode} message="${res.result.diagnosticMessage}"` + : "" + ); + if (res.constructor.name == "SearchResultReference") { + this._logger.debug("References=", res.result); + } + let callback = this._callbackMap.get(res.messageId); + if (callback) { + callback(res); + if ( + !["SearchResultEntry", "SearchResultReference"].includes( + res.constructor.name + ) + ) { + this._callbackMap.delete(res.messageId); + } + } + data = data.slice(res.byteLength); + if (i % 10 == 0) { + // Prevent blocking the main thread for too long. + await new Promise(resolve => setTimeout(resolve)); + } + } + this._handleNextDataEvent(); + }; + + /** + * Process a queued data event, if there is any. + */ + _handleNextDataEvent() { + this._processingData = false; + let next = this._dataEventsQueue.shift(); + if (next) { + this._onData(next); + } + } + + /** + * The close event handler. + */ + _onClose = () => { + this._logger.debug("Connection closed"); + }; + + /** + * The error event handler. + * + * @param {TCPSocketErrorEvent} event - The error event. + */ + _onError = async event => { + this._logger.error(event); + this._socket.close(); + this.onError( + event.errorCode, + await event.target.transport?.tlsSocketControl?.asyncGetSecurityInfo() + ); + }; + + /** + * Send a message to the server. + * + * @param {LDAPMessage} msg - The message to send. + * @param {Function} callback - Callback function when receiving server responses. + * @returns {number} The id of the sent message. + */ + _send(msg, callback) { + if (callback) { + this._callbackMap.set(this._messageId, callback); + } + this._logger.debug(`C: [${this._messageId}] ${msg.constructor.name}`); + this._socket.send(msg.toBER(this._messageId)); + return this._messageId++; + } +} diff --git a/comm/mailnews/addrbook/modules/LDAPConnection.jsm b/comm/mailnews/addrbook/modules/LDAPConnection.jsm new file mode 100644 index 0000000000..f9a66f47a7 --- /dev/null +++ b/comm/mailnews/addrbook/modules/LDAPConnection.jsm @@ -0,0 +1,53 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["LDAPConnection"]; + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "LDAPClient", + "resource:///modules/LDAPClient.jsm" +); + +/** + * A module to manage LDAP connection. + * + * @implements {nsILDAPConnection} + */ +class LDAPConnection { + QueryInterface = ChromeUtils.generateQI(["nsILDAPConnection"]); + + get bindName() { + return this._bindName; + } + + init(url, bindName, listener, closure, version) { + let useSecureTransport = url.scheme == "ldaps"; + let port = url.port; + if (port == -1) { + // -1 corresponds to the protocol's default port. + port = useSecureTransport ? 636 : 389; + } + this.client = new lazy.LDAPClient(url.host, port, useSecureTransport); + this._url = url; + this._bindName = bindName; + this.client.onOpen = () => { + listener.onLDAPInit(); + }; + this.client.onError = (status, secInfo) => { + listener.onLDAPError(status, secInfo, `${url.host}:${port}`); + }; + this.client.connect(); + } + + get wrappedJSObject() { + return this; + } +} + +LDAPConnection.prototype.classID = Components.ID( + "{f87b71b5-2a0f-4b37-8e4f-3c899f6b8432}" +); diff --git a/comm/mailnews/addrbook/modules/LDAPDirectory.jsm b/comm/mailnews/addrbook/modules/LDAPDirectory.jsm new file mode 100644 index 0000000000..758cde2ed9 --- /dev/null +++ b/comm/mailnews/addrbook/modules/LDAPDirectory.jsm @@ -0,0 +1,230 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["LDAPDirectory"]; + +const { AddrBookDirectory } = ChromeUtils.import( + "resource:///modules/AddrBookDirectory.jsm" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + QueryStringToExpression: "resource:///modules/QueryStringToExpression.jsm", +}); + +/** + * @implements {nsIAbLDAPDirectory} + * @implements {nsIAbDirectory} + */ + +class LDAPDirectory extends AddrBookDirectory { + QueryInterface = ChromeUtils.generateQI([ + "nsIAbLDAPDirectory", + "nsIAbDirectory", + ]); + + init(uri) { + this._uri = uri; + + let searchIndex = uri.indexOf("?"); + this._dirPrefId = uri.substr( + "moz-abldapdirectory://".length, + searchIndex == -1 ? undefined : searchIndex + ); + + super.init(uri); + } + + get readOnly() { + return true; + } + + get isRemote() { + return true; + } + + get isSecure() { + return this.lDAPURL.scheme == "ldaps"; + } + + get propertiesChromeURI() { + return "chrome://messenger/content/addressbook/pref-directory-add.xhtml"; + } + + get dirType() { + return Ci.nsIAbManager.LDAP_DIRECTORY_TYPE; + } + + get replicationFileName() { + return this.getStringValue("filename"); + } + + set replicationFileName(value) { + this.setStringValue("filename", value); + } + + get replicationFile() { + return lazy.FileUtils.getFile("ProfD", [this.replicationFileName]); + } + + get protocolVersion() { + return this.getStringValue("protocolVersion", "3") == "3" + ? Ci.nsILDAPConnection.VERSION3 + : Ci.nsILDAPConnection.VERSION2; + } + + set protocolVersion(value) { + this.setStringValue( + "protocolVersion", + value == Ci.nsILDAPConnection.VERSION3 ? "3" : "2" + ); + } + + get saslMechanism() { + return this.getStringValue("auth.saslmech"); + } + + set saslMechanism(value) { + this.setStringValue("auth.saslmech", value); + } + + get authDn() { + return this.getStringValue("auth.dn"); + } + + set authDn(value) { + this.setStringValue("auth.dn", value); + } + + get maxHits() { + return this.getIntValue("maxHits", 100); + } + + set maxHits(value) { + this.setIntValue("maxHits", value); + } + + get attributeMap() { + let mapSvc = Cc[ + "@mozilla.org/addressbook/ldap-attribute-map-service;1" + ].createInstance(Ci.nsIAbLDAPAttributeMapService); + return mapSvc.getMapForPrefBranch(this._dirPrefId); + } + + get lDAPURL() { + let uri = this.getStringValue("uri") || `ldap://${this._uri.slice(22)}`; + return Services.io.newURI(uri).QueryInterface(Ci.nsILDAPURL); + } + + set lDAPURL(uri) { + this.setStringValue("uri", uri.spec); + } + + get childCardCount() { + return 0; + } + + get childCards() { + if (Services.io.offline) { + return this.replicationDB.childCards; + } + return super.childCards; + } + + /** + * @see {AddrBookDirectory} + */ + get cards() { + return new Map(); + } + + /** + * @see {AddrBookDirectory} + */ + get lists() { + return new Map(); + } + + get replicationDB() { + this._replicationDB?.cleanUp(); + this._replicationDB = Cc[ + "@mozilla.org/addressbook/directory;1?type=jsaddrbook" + ].createInstance(Ci.nsIAbDirectory); + this._replicationDB.init(`jsaddrbook://${this.replicationFileName}`); + return this._replicationDB; + } + + getCardFromProperty(property, value, caseSensitive) { + return null; + } + + search(queryString, searchString, listener) { + if (Services.io.offline) { + this.replicationDB.search(queryString, searchString, listener); + return; + } + this._query = Cc[ + "@mozilla.org/addressbook/ldap-directory-query;1" + ].createInstance(Ci.nsIAbDirectoryQuery); + + let args = Cc[ + "@mozilla.org/addressbook/directory/query-arguments;1" + ].createInstance(Ci.nsIAbDirectoryQueryArguments); + args.expression = lazy.QueryStringToExpression.convert(queryString); + args.querySubDirectories = true; + args.typeSpecificArg = this.attributeMap; + + this._query.doQuery(this, args, listener, this.maxHits, 0); + } + + useForAutocomplete(identityKey) { + // If we're online, then don't allow search during local autocomplete - must + // use the separate LDAP autocomplete session due to the current interfaces + let useDirectory = Services.prefs.getBoolPref( + "ldap_2.autoComplete.useDirectory", + false + ); + if (!Services.io.offline || (!useDirectory && !identityKey)) { + return false; + } + + let prefName = ""; + if (identityKey) { + // If we have an identity string, try and find out the required directory + // server. + let identity = MailServices.accounts.getIdentity(identityKey); + if (identity.overrideGlobalPref) { + prefName = identity.directoryServer; + } + if (!prefName && !useDirectory) { + return false; + } + } + if (!prefName) { + prefName = Services.prefs.getCharPref( + "ldap_2.autoComplete.directoryServer" + ); + } + if (prefName == this.dirPrefId) { + return this.replicationFile.exists(); + } + + return false; + } +} + +LDAPDirectory.prototype.classID = Components.ID( + "{8683e821-f1b0-476d-ac15-07771c79bb11}" +); diff --git a/comm/mailnews/addrbook/modules/LDAPDirectoryQuery.jsm b/comm/mailnews/addrbook/modules/LDAPDirectoryQuery.jsm new file mode 100644 index 0000000000..88291cbaed --- /dev/null +++ b/comm/mailnews/addrbook/modules/LDAPDirectoryQuery.jsm @@ -0,0 +1,218 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["LDAPDirectoryQuery"]; + +const { LDAPListenerBase } = ChromeUtils.import( + "resource:///modules/LDAPListenerBase.jsm" +); + +/** + * Convert a nsIAbBooleanExpression to a filter string. + * + * @param {nsIAbLDAPAttributeMap} attrMap - A mapping between address book + * properties and ldap attributes. + * @param {nsIAbBooleanExpression} exp - The expression to convert. + * @returns {string} + */ +function boolExpressionToFilter(attrMap, exp) { + let filter = "("; + filter += + { + [Ci.nsIAbBooleanOperationTypes.AND]: "&", + [Ci.nsIAbBooleanOperationTypes.OR]: "|", + [Ci.nsIAbBooleanOperationTypes.NOT]: "!", + }[exp.operation] || ""; + + if (exp.expressions) { + for (let childExp of exp.expressions) { + if (childExp instanceof Ci.nsIAbBooleanExpression) { + filter += boolExpressionToFilter(attrMap, childExp); + } else if (childExp instanceof Ci.nsIAbBooleanConditionString) { + filter += boolConditionToFilter(attrMap, childExp); + } + } + } + + filter += ")"; + return filter; +} + +/** + * Convert a nsIAbBooleanConditionString to a filter string. + * + * @param {nsIAbLDAPAttributeMap} attrMap - A mapping between addressbook + * properties and ldap attributes. + * @param {nsIAbBooleanConditionString} exp - The expression to convert. + * @returns {string} + */ +function boolConditionToFilter(attrMap, exp) { + let attr = attrMap.getFirstAttribute(exp.name); + if (!attr) { + return ""; + } + switch (exp.condition) { + case Ci.nsIAbBooleanConditionTypes.DoesNotExist: + return `(!(${attr}=*))`; + case Ci.nsIAbBooleanConditionTypes.Exists: + return `(${attr}=*)`; + case Ci.nsIAbBooleanConditionTypes.Contains: + return `(${attr}=*${exp.value}*)`; + case Ci.nsIAbBooleanConditionTypes.DoesNotContain: + return `(!(${attr}=*${exp.value}*))`; + case Ci.nsIAbBooleanConditionTypes.Is: + return `(${attr}=${exp.value})`; + case Ci.nsIAbBooleanConditionTypes.IsNot: + return `(!(${attr}=${exp.value}))`; + case Ci.nsIAbBooleanConditionTypes.BeginsWith: + return `(${attr}=${exp.value}*)`; + case Ci.nsIAbBooleanConditionTypes.EndsWith: + return `(${attr}=*${exp.value})`; + case Ci.nsIAbBooleanConditionTypes.LessThan: + return `(${attr}<=${exp.value})`; + case Ci.nsIAbBooleanConditionTypes.GreaterThan: + return `(${attr}>=${exp.value})`; + case Ci.nsIAbBooleanConditionTypes.SoundsLike: + return `(${attr}~=${exp.value})`; + default: + return ""; + } +} + +/** + * @implements {nsIAbDirectoryQuery} + * @implements {nsILDAPMessageListener} + */ +class LDAPDirectoryQuery extends LDAPListenerBase { + QueryInterface = ChromeUtils.generateQI([ + "nsIAbDirectoryQuery", + "nsILDAPMessageListener", + ]); + + i = 0; + + doQuery(directory, args, listener, limit, timeout) { + this._directory = directory.QueryInterface(Ci.nsIAbLDAPDirectory); + this._listener = listener; + this._attrMap = args.typeSpecificArg; + this._filter = + args.filter || boolExpressionToFilter(this._attrMap, args.expression); + this._limit = limit; + this._timeout = timeout; + + let urlFilter = this._directory.lDAPURL.filter; + // If urlFilter is empty or the default "(objectclass=*)", do nothing. + if (urlFilter && urlFilter != "(objectclass=*)") { + if (!urlFilter.startsWith("(")) { + urlFilter = `(${urlFilter})`; + } + this._filter = `(&${urlFilter}${this._filter})`; + } + + this._connection = Cc[ + "@mozilla.org/network/ldap-connection;1" + ].createInstance(Ci.nsILDAPConnection); + this._operation = Cc[ + "@mozilla.org/network/ldap-operation;1" + ].createInstance(Ci.nsILDAPOperation); + + this._connection.init( + directory.lDAPURL, + directory.authDn, + this, + null, + directory.protocolVersion + ); + return this.i++; + } + + stopQuery(contextId) { + this._operation?.abandonExt(); + } + + /** + * @see nsILDAPMessageListener + */ + onLDAPMessage(msg) { + switch (msg.type) { + case Ci.nsILDAPMessage.RES_BIND: + this._onLDAPBind(msg); + break; + case Ci.nsILDAPMessage.RES_SEARCH_ENTRY: + this._onLDAPSearchEntry(msg); + break; + case Ci.nsILDAPMessage.RES_SEARCH_RESULT: + this._onLDAPSearchResult(msg); + break; + default: + break; + } + } + + /** + * @see nsILDAPMessageListener + */ + onLDAPError(status, secInfo, location) { + this._onSearchFinished(status, secInfo, location); + } + + /** + * @see LDAPListenerBase + */ + _actionOnBindSuccess() { + let ldapUrl = this._directory.lDAPURL; + this._operation.searchExt( + ldapUrl.dn, + ldapUrl.scope, + this._filter, + ldapUrl.attributes, + this._timeout, + this._limit + ); + } + + /** + * @see LDAPListenerBase + */ + _actionOnBindFailure() { + this._onSearchFinished(Cr.NS_ERROR_FAILURE); + } + + /** + * Handler of nsILDAPMessage.RES_SEARCH_ENTRY message. + * + * @param {nsILDAPMessage} msg - The received LDAP message. + */ + _onLDAPSearchEntry(msg) { + let newCard = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance( + Ci.nsIAbCard + ); + this._attrMap.setCardPropertiesFromLDAPMessage(msg, newCard); + newCard.directoryUID = this._directory.UID; + this._listener.onSearchFoundCard(newCard); + } + + /** + * Handler of nsILDAPMessage.RES_SEARCH_RESULT message. + * + * @param {nsILDAPMessage} msg - The received LDAP message. + */ + _onLDAPSearchResult(msg) { + this._onSearchFinished( + [Ci.nsILDAPErrors.SUCCESS, Ci.nsILDAPErrors.SIZELIMIT_EXCEEDED].includes( + msg.errorCode + ) + ? Cr.NS_OK + : Cr.NS_ERROR_FAILURE + ); + } + + _onSearchFinished(status, secInfo, location) { + this._listener.onSearchFinished(status, false, secInfo, location); + } +} + +LDAPDirectoryQuery.prototype.classID = Components.ID( + "{5ad5d311-1a50-43db-a03c-63d45f443903}" +); diff --git a/comm/mailnews/addrbook/modules/LDAPListenerBase.jsm b/comm/mailnews/addrbook/modules/LDAPListenerBase.jsm new file mode 100644 index 0000000000..486dcaffbe --- /dev/null +++ b/comm/mailnews/addrbook/modules/LDAPListenerBase.jsm @@ -0,0 +1,117 @@ +/* -*- Mode: JavaScript; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["LDAPListenerBase"]; + +/** + * @implements {nsILDAPMessageListener} + */ +class LDAPListenerBase { + /** + * @see nsILDAPMessageListener + */ + async onLDAPInit() { + let outPassword = {}; + if (this._directory.authDn && this._directory.saslMechanism != "GSSAPI") { + // If authDn is set, we're expected to use it to get a password. + let bundle = Services.strings.createBundle( + "chrome://mozldap/locale/ldap.properties" + ); + + let authPrompt = Services.ww.getNewAuthPrompter( + Services.wm.getMostRecentWindow(null) + ); + await authPrompt.asyncPromptPassword( + bundle.GetStringFromName("authPromptTitle"), + bundle.formatStringFromName("authPromptText", [ + this._directory.lDAPURL.host, + ]), + this._directory.lDAPURL.spec, + Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY, + outPassword + ); + } + this._operation.init(this._connection, this, null); + + if (this._directory.saslMechanism != "GSSAPI") { + this._operation.simpleBind(outPassword.value); + return; + } + + // Handle GSSAPI now. + this._operation.saslBind( + `ldap@${this._directory.lDAPURL.host}`, + "GSSAPI", + "sasl-gssapi" + ); + } + + /** + * Handler of nsILDAPMessage.RES_BIND message. + * + * @param {nsILDAPMessage} msg - The received LDAP message. + */ + _onLDAPBind(msg) { + let errCode = msg.errorCode; + if ( + errCode == Ci.nsILDAPErrors.INAPPROPRIATE_AUTH || + errCode == Ci.nsILDAPErrors.INVALID_CREDENTIALS + ) { + // Login failed, remove any existing login(s). + let ldapUrl = this._directory.lDAPURL; + let logins = Services.logins.findLogins( + ldapUrl.prePath, + "", + ldapUrl.spec + ); + for (let login of logins) { + Services.logins.removeLogin(login); + } + // Trigger the auth prompt. + this.onLDAPInit(); + return; + } + if (errCode != Ci.nsILDAPErrors.SUCCESS) { + this._actionOnBindFailure(); + return; + } + this._actionOnBindSuccess(); + } + + /** + * @see nsILDAPMessageListener + * @abstract + */ + onLDAPMessage() { + throw new Components.Exception( + `${this.constructor.name} does not implement onLDAPMessage.`, + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + /** + * Callback when BindResponse succeeded. + * + * @abstract + */ + _actionOnBindSuccess() { + throw new Components.Exception( + `${this.constructor.name} does not implement _actionOnBindSuccess.`, + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + /** + * Callback when BindResponse failed. + * + * @abstract + */ + _actionOnBindFailure() { + throw new Components.Exception( + `${this.constructor.name} does not implement _actionOnBindFailure.`, + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } +} diff --git a/comm/mailnews/addrbook/modules/LDAPMessage.jsm b/comm/mailnews/addrbook/modules/LDAPMessage.jsm new file mode 100644 index 0000000000..6ee7574605 --- /dev/null +++ b/comm/mailnews/addrbook/modules/LDAPMessage.jsm @@ -0,0 +1,632 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = [ + "AbandonRequest", + "BindRequest", + "UnbindRequest", + "SearchRequest", + "LDAPResponse", +]; + +var { asn1js } = ChromeUtils.importESModule("resource:///modules/asn1js.mjs"); + +/** + * A base class for all LDAP request and response messages, see + * rfc4511#section-4.1.1. + * + * @property {number} messageId - The message id. + * @property {LocalBaseBlock} protocolOp - The message content, in a data + * structure provided by asn1js. + */ +class LDAPMessage { + /** + * Encode the current message by Basic Encoding Rules (BER). + * + * @param {number} messageId - The id of the current message. + * @returns {ArrayBuffer} BER encoded message. + */ + toBER(messageId = this.messageId) { + let msg = new asn1js.Sequence({ + value: [new asn1js.Integer({ value: messageId }), this.protocolOp], + }); + return msg.toBER(); + } + + static TAG_CLASS_APPLICATION = 2; + static TAG_CLASS_CONTEXT = 3; + + /** + * Get the idBlock of [APPLICATION n]. + * + * @param {number} tagNumber - The tag number of this block. + */ + _getApplicationId(tagNumber) { + return { + tagClass: LDAPMessage.TAG_CLASS_APPLICATION, + tagNumber, + }; + } + + /** + * Get the idBlock of context-specific [n]. + * + * @param {number} tagNumber - The tag number of this block. + */ + _getContextId(tagNumber) { + return { + tagClass: LDAPMessage.TAG_CLASS_CONTEXT, + tagNumber, + }; + } + + /** + * Create a string block with context-specific [n]. + * + * @param {number} tagNumber - The tag number of this block. + * @param {string} value - The string value of this block. + * @returns {LocalBaseBlock} + */ + _contextStringBlock(tagNumber, value) { + return new asn1js.Primitive({ + idBlock: this._getContextId(tagNumber), + valueHex: new TextEncoder().encode(value), + }); + } +} + +class BindRequest extends LDAPMessage { + static APPLICATION = 0; + + AUTH_SIMPLE = 0; + AUTH_SASL = 3; + + /** + * @param {string} dn - The name to bind. + * @param {string} password - The password. + * @param {object} sasl - The SASL configs. + * @param {string} sasl.mechanism - The SASL mechanism e.g. sasl-gssapi. + * @param {Uint8Array} sasl.credentials - The credential token for the request. + */ + constructor(dn, password, sasl) { + super(); + let authBlock; + if (sasl) { + authBlock = new asn1js.Constructed({ + idBlock: this._getContextId(this.AUTH_SASL), + value: [ + new asn1js.OctetString({ + valueHex: new TextEncoder().encode(sasl.mechanism), + }), + new asn1js.OctetString({ + valueHex: sasl.credentials, + }), + ], + }); + } else { + authBlock = new asn1js.Primitive({ + idBlock: this._getContextId(this.AUTH_SIMPLE), + valueHex: new TextEncoder().encode(password), + }); + } + this.protocolOp = new asn1js.Constructed({ + // [APPLICATION 0] + idBlock: this._getApplicationId(BindRequest.APPLICATION), + value: [ + // version + new asn1js.Integer({ value: 3 }), + // name + new asn1js.OctetString({ + valueHex: new TextEncoder().encode(dn), + }), + // authentication + authBlock, + ], + }); + } +} + +class UnbindRequest extends LDAPMessage { + static APPLICATION = 2; + + protocolOp = new asn1js.Primitive({ + // [APPLICATION 2] + idBlock: this._getApplicationId(UnbindRequest.APPLICATION), + }); +} + +class SearchRequest extends LDAPMessage { + static APPLICATION = 3; + + // Filter CHOICE. + FILTER_AND = 0; + FILTER_OR = 1; + FILTER_NOT = 2; + FILTER_EQUALITY_MATCH = 3; + FILTER_SUBSTRINGS = 4; + FILTER_GREATER_OR_EQUAL = 5; + FILTER_LESS_OR_EQUAL = 6; + FILTER_PRESENT = 7; + FILTER_APPROX_MATCH = 8; + FILTER_EXTENSIBLE_MATCH = 9; + + // SubstringFilter SEQUENCE. + SUBSTRINGS_INITIAL = 0; + SUBSTRINGS_ANY = 1; + SUBSTRINGS_FINAL = 2; + + // MatchingRuleAssertion SEQUENCE. + MATCHING_RULE = 1; // optional + MATCHING_TYPE = 2; // optional + MATCHING_VALUE = 3; + MATCHING_DN = 4; // default to FALSE + + /** + * @param {string} dn - The name to search. + * @param {number} scope - The scope to search. + * @param {string} filter - The filter string, e.g. "(&(|(k1=v1)(k2=v2)))". + * @param {string} attributes - Attributes to include in the search result. + * @param {number} timeout - The seconds to wait. + * @param {number} limit - Maximum number of entries to return. + */ + constructor(dn, scope, filter, attributes, timeout, limit) { + super(); + this.protocolOp = new asn1js.Constructed({ + // [APPLICATION 3] + idBlock: this._getApplicationId(SearchRequest.APPLICATION), + value: [ + // base DN + new asn1js.OctetString({ + valueHex: new TextEncoder().encode(dn), + }), + // scope + new asn1js.Enumerated({ + value: scope, + }), + // derefAliases + new asn1js.Enumerated({ + value: 0, + }), + // sizeLimit + new asn1js.Integer({ value: limit }), + // timeLimit + new asn1js.Integer({ value: timeout }), + // typesOnly + new asn1js.Boolean({ value: false }), + // filter + this._convertFilterToBlock(filter), + // attributes + new asn1js.Sequence({ + value: attributes + .split(",") + .filter(Boolean) + .map( + attr => + new asn1js.OctetString({ + valueHex: new TextEncoder().encode(attr), + }) + ), + }), + ], + }); + } + + /** + * Parse a single filter value "key=value" to [filterId, key, value]. + * + * @param {string} filter - A single filter value without parentheses. + * @returns {(number|string)[]} An array [filterId, key, value] as + * [number, string, string] + */ + _parseFilterValue(filter) { + for (let cond of [">=", "<=", "~=", ":=", "="]) { + let index = filter.indexOf(cond); + if (index > 0) { + let k = filter.slice(0, index); + let v = filter.slice(index + cond.length); + let filterId = { + ">=": this.FILTER_GREATER_OR_EQUAL, + "<=": this.FILTER_LESS_OR_EQUAL, + "~=": this.FILTER_APPROX_MATCH, + ":=": this.FILTER_EXTENSIBLE_MATCH, + }[cond]; + if (!filterId) { + if (v == "*") { + filterId = this.FILTER_PRESENT; + } else if (!v.includes("*")) { + filterId = this.FILTER_EQUALITY_MATCH; + } else { + filterId = this.FILTER_SUBSTRINGS; + v = v.split("*"); + } + } + return [filterId, k, v]; + } + } + throw Components.Exception( + `Invalid filter: ${filter}`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + + /** + * Parse a full filter string to an array of tokens. + * + * @param {string} filter - The full filter string to parse. + * @param {number} depth - The depth of a token. + * @param {object[]} tokens - The tokens to return. + * @param {"op"|"field"} tokens[].type - The token type. + * @param {number} tokens[].depth - The token depth. + * @param {string|string[]} tokens[].value - The token value. + */ + _parseFilter(filter, depth = 0, tokens = []) { + while (filter[0] == ")" && depth > 0) { + depth--; + filter = filter.slice(1); + } + if (filter.length == 0) { + // End of input. + return tokens; + } + if (filter[0] != "(") { + throw Components.Exception( + `Invalid filter: ${filter}`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + filter = filter.slice(1); + let nextOpen = filter.indexOf("("); + let nextClose = filter.indexOf(")"); + + if (nextOpen != -1 && nextOpen < nextClose) { + // Case: "OP(" + depth++; + tokens.push({ + type: "op", + depth, + value: { + "&": this.FILTER_AND, + "|": this.FILTER_OR, + "!": this.FILTER_NOT, + }[filter.slice(0, nextOpen)], + }); + this._parseFilter(filter.slice(nextOpen), depth, tokens); + } else if (nextClose != -1) { + // Case: "key=value)" + tokens.push({ + type: "field", + depth, + value: this._parseFilterValue(filter.slice(0, nextClose)), + }); + this._parseFilter(filter.slice(nextClose + 1), depth, tokens); + } + return tokens; + } + + /** + * Parse a filter string to a LocalBaseBlock. + * + * @param {string} filter - The filter string to parse. + * @returns {LocalBaseBlock} + */ + _convertFilterToBlock(filter) { + if (!filter.startsWith("(")) { + // Make sure filter is wrapped in parens, see rfc2254#section-4. + filter = `(${filter})`; + } + let tokens = this._parseFilter(filter); + let stack = []; + for (let { type, depth, value } of tokens) { + while (depth < stack.length) { + // We are done with the current block, go one level up. + stack.pop(); + } + if (type == "op") { + if (depth == stack.length) { + // We are done with the current block, go one level up. + stack.pop(); + } + // Found a new block, go one level down. + let parent = stack.slice(-1)[0]; + let curBlock = new asn1js.Constructed({ + idBlock: this._getContextId(value), + }); + stack.push(curBlock); + if (parent) { + parent.valueBlock.value.push(curBlock); + } + } else if (type == "field") { + let [tagNumber, field, fieldValue] = value; + let block; + let idBlock = this._getContextId(tagNumber); + if (tagNumber == this.FILTER_PRESENT) { + // A present filter. + block = new asn1js.Primitive({ + idBlock, + valueHex: new TextEncoder().encode(field), + }); + } else if (tagNumber == this.FILTER_EXTENSIBLE_MATCH) { + // An extensibleMatch filter is in the form of + // <type>:dn:<rule>:=<value>. We need to further parse the field. + let parts = field.split(":"); + let value = []; + if (parts.length == 3) { + // field is <type>:dn:<rule>. + if (parts[2]) { + value.push( + this._contextStringBlock(this.MATCHING_RULE, parts[2]) + ); + } + if (parts[0]) { + value.push( + this._contextStringBlock(this.MATCHING_TYPE, parts[0]) + ); + } + value.push( + this._contextStringBlock(this.MATCHING_VALUE, fieldValue) + ); + if (parts[1] == "dn") { + let dn = new asn1js.Boolean({ + value: true, + }); + dn.idBlock.tagClass = LDAPMessage.TAG_CLASS_CONTEXT; + dn.idBlock.tagNumber = this.MATCHING_DN; + value.push(dn); + } + } else if (parts.length == 2) { + // field is <type>:<rule>. + if (parts[1]) { + value.push( + this._contextStringBlock(this.MATCHING_RULE, parts[1]) + ); + } + + if (parts[0]) { + value.push( + this._contextStringBlock(this.MATCHING_TYPE, parts[0]) + ); + } + value.push( + this._contextStringBlock(this.MATCHING_VALUE, fieldValue) + ); + } else { + // field is <type>. + value = [ + this._contextStringBlock(this.MATCHING_TYPE, field), + this._contextStringBlock(this.MATCHING_VALUE, fieldValue), + ]; + } + block = new asn1js.Constructed({ + idBlock, + value, + }); + } else if (tagNumber != this.FILTER_SUBSTRINGS) { + // A filter that is not substrings filter. + block = new asn1js.Constructed({ + idBlock, + value: [ + new asn1js.OctetString({ + valueHex: new TextEncoder().encode(field), + }), + new asn1js.OctetString({ + valueHex: new TextEncoder().encode(fieldValue), + }), + ], + }); + } else { + // A substrings filter. + let substringsSeq = new asn1js.Sequence(); + block = new asn1js.Constructed({ + idBlock, + value: [ + new asn1js.OctetString({ + valueHex: new TextEncoder().encode(field), + }), + substringsSeq, + ], + }); + for (let i = 0; i < fieldValue.length; i++) { + let v = fieldValue[i]; + if (!v.length) { + // Case: * + continue; + } else if (i < fieldValue.length - 1) { + // Case: abc* + substringsSeq.valueBlock.value.push( + new asn1js.Primitive({ + idBlock: this._getContextId( + i == 0 ? this.SUBSTRINGS_INITIAL : this.SUBSTRINGS_ANY + ), + valueHex: new TextEncoder().encode(v), + }) + ); + } else { + // Case: *abc + substringsSeq.valueBlock.value.push( + new asn1js.Primitive({ + idBlock: this._getContextId(this.SUBSTRINGS_FINAL), + valueHex: new TextEncoder().encode(v), + }) + ); + } + } + } + let curBlock = stack.slice(-1)[0]; + if (curBlock) { + curBlock.valueBlock.value.push(block); + } else { + stack.push(block); + } + } + } + + return stack[0]; + } +} + +class AbandonRequest extends LDAPMessage { + static APPLICATION = 16; + + /** + * @param {string} messageId - The messageId to abandon. + */ + constructor(messageId) { + super(); + this.protocolOp = new asn1js.Integer({ value: messageId }); + // [APPLICATION 16] + this.protocolOp.idBlock.tagClass = LDAPMessage.TAG_CLASS_APPLICATION; + this.protocolOp.idBlock.tagNumber = AbandonRequest.APPLICATION; + } +} + +class LDAPResult { + /** + * @param {number} resultCode - The result code. + * @param {string} matchedDN - For certain result codes, matchedDN is the last entry used. + * @param {string} diagnosticMessage - A diagnostic message returned by the server. + */ + constructor(resultCode, matchedDN, diagnosticMessage) { + this.resultCode = resultCode; + this.matchedDN = matchedDN; + this.diagnosticMessage = diagnosticMessage; + } +} + +/** + * A base class for all LDAP response messages. + * + * @property {LDAPResult} result - The result of a response. + */ +class LDAPResponse extends LDAPMessage { + /** + * @param {number} messageId - The message id. + * @param {LocalBaseBlock} protocolOp - The message content. + * @param {number} byteLength - The byte size of this message in raw BER form. + */ + constructor(messageId, protocolOp, byteLength) { + super(); + this.messageId = messageId; + this.protocolOp = protocolOp; + this.byteLength = byteLength; + } + + /** + * Find the corresponding response class name from a tag number. + * + * @param {number} tagNumber - The tag number of a block. + * @returns {LDAPResponse} + */ + static _getResponseClassFromTagNumber(tagNumber) { + return [ + SearchResultEntry, + SearchResultDone, + SearchResultReference, + BindResponse, + ExtendedResponse, + ].find(x => x.APPLICATION == tagNumber); + } + + /** + * Decode a raw server response to LDAPResponse instance. + * + * @param {ArrayBuffer} buffer - The raw message received from the server. + * @returns {LDAPResponse} A concrete instance of LDAPResponse subclass. + */ + static fromBER(buffer) { + let decoded = asn1js.fromBER(buffer); + if (decoded.offset == -1 || decoded.result.error) { + throw Components.Exception( + decoded.result.error, + Cr.NS_ERROR_CANNOT_CONVERT_DATA + ); + } + let value = decoded.result.valueBlock.value; + let protocolOp = value[1]; + if (protocolOp.idBlock.tagClass != this.TAG_CLASS_APPLICATION) { + throw Components.Exception( + `Unexpected tagClass ${protocolOp.idBlock.tagClass}`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + let ProtocolOp = this._getResponseClassFromTagNumber( + protocolOp.idBlock.tagNumber + ); + if (!ProtocolOp) { + throw Components.Exception( + `Unexpected tagNumber ${protocolOp.idBlock.tagNumber}`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + let op = new ProtocolOp( + value[0].valueBlock.valueDec, + protocolOp, + decoded.offset + ); + op.parse(); + return op; + } + + /** + * Parse the protocolOp part of a LDAPMessage to LDAPResult. For LDAP + * responses that are simply LDAPResult, reuse this function. Other responses + * need to implement this function. + */ + parse() { + let value = this.protocolOp.valueBlock.value; + let resultCode = value[0].valueBlock.valueDec; + let matchedDN = new TextDecoder().decode(value[1].valueBlock.valueHex); + let diagnosticMessage = new TextDecoder().decode( + value[2].valueBlock.valueHex + ); + this.result = new LDAPResult(resultCode, matchedDN, diagnosticMessage); + } +} + +class BindResponse extends LDAPResponse { + static APPLICATION = 1; + + parse() { + super.parse(); + let serverSaslCredsBlock = this.protocolOp.valueBlock.value[3]; + if (serverSaslCredsBlock) { + this.result.serverSaslCreds = serverSaslCredsBlock.valueBlock.valueHex; + } + } +} + +class SearchResultEntry extends LDAPResponse { + static APPLICATION = 4; + + parse() { + let value = this.protocolOp.valueBlock.value; + let objectName = new TextDecoder().decode(value[0].valueBlock.valueHex); + let attributes = {}; + for (let attr of value[1].valueBlock.value) { + let attrValue = attr.valueBlock.value; + let type = new TextDecoder().decode(attrValue[0].valueBlock.valueHex); + let vals = attrValue[1].valueBlock.value.map(v => v.valueBlock.valueHex); + attributes[type] = vals; + } + this.result = { objectName, attributes }; + } +} + +class SearchResultDone extends LDAPResponse { + static APPLICATION = 5; +} + +class SearchResultReference extends LDAPResponse { + static APPLICATION = 19; + + parse() { + let value = this.protocolOp.valueBlock.value; + this.result = value.map(block => + new TextDecoder().decode(block.valueBlock.valueHex) + ); + } +} + +class ExtendedResponse extends LDAPResponse { + static APPLICATION = 24; +} diff --git a/comm/mailnews/addrbook/modules/LDAPOperation.jsm b/comm/mailnews/addrbook/modules/LDAPOperation.jsm new file mode 100644 index 0000000000..d0e2d64a54 --- /dev/null +++ b/comm/mailnews/addrbook/modules/LDAPOperation.jsm @@ -0,0 +1,198 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["LDAPOperation"]; + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "LDAPClient", + "resource:///modules/LDAPClient.jsm" +); + +/** + * A module to manage LDAP operation. + * + * @implements {nsILDAPOperation} + */ +class LDAPOperation { + QueryInterface = ChromeUtils.generateQI(["nsILDAPOperation"]); + + init(connection, listener, closure) { + this._listener = listener; + this._connection = connection; + this._client = connection.wrappedJSObject.client; + + this._referenceUrls = []; + + // Cache request arguments to use when searching references. + this._simpleBindPassword = null; + this._saslBindArgs = null; + this._searchArgs = null; + } + + simpleBind(password) { + this._password = password; + try { + this._messageId = this._client.bind( + this._connection.bindName, + password, + res => this._onBindSuccess(res.result.resultCode) + ); + } catch (e) { + this._listener.onLDAPError(e.result, null, ""); + } + } + + saslBind(service, mechanism, authModuleType, serverCredentials) { + this._saslBindArgs = [service, mechanism, authModuleType]; + try { + this._client.saslBind( + service, + mechanism, + authModuleType, + serverCredentials, + res => { + if (res.result.resultCode == Ci.nsILDAPErrors.SASL_BIND_IN_PROGRESS) { + this.saslBind( + service, + mechanism, + authModuleType, + res.result.serverSaslCreds + ); + } else if (res.result.resultCode == Ci.nsILDAPErrors.SUCCESS) { + this._onBindSuccess(res.result.resultCode); + } + } + ); + } catch (e) { + this._listener.onLDAPError(e.result, null, ""); + } + } + + searchExt(baseDN, scope, filter, attributes, timeout, limit) { + this._searchArgs = [baseDN, scope, filter, attributes, timeout, limit]; + try { + this._messageId = this._client.search( + baseDN, + scope, + filter, + attributes, + timeout, + limit, + res => { + if (res.constructor.name == "SearchResultEntry") { + this._listener.onLDAPMessage({ + QueryInterface: ChromeUtils.generateQI(["nsILDAPMessage"]), + errorCode: 0, + type: Ci.nsILDAPMessage.RES_SEARCH_ENTRY, + getAttributes() { + return Object.keys(res.result.attributes); + }, + // Find the matching attribute name while ignoring the case. + _getAttribute(attr) { + attr = attr.toLowerCase(); + return this.getAttributes().find(x => x.toLowerCase() == attr); + }, + getValues(attr) { + attr = this._getAttribute(attr); + return res.result.attributes[attr]?.map(v => + new TextDecoder().decode(v) + ); + }, + getBinaryValues(attr) { + attr = this._getAttribute(attr); + return res.result.attributes[attr]?.map(v => ({ + // @see nsILDAPBERValue + get: () => new Uint8Array(v), + })); + }, + }); + } else if (res.constructor.name == "SearchResultReference") { + this._referenceUrls.push(...res.result); + } else if (res.constructor.name == "SearchResultDone") { + // NOTE: we create a new connection for every search, can be changed + // to reuse connections. + this._client.onError = () => {}; + this._client.unbind(); + this._messageId = null; + if (this._referenceUrls.length) { + this._searchReference(this._referenceUrls.shift()); + } else { + this._listener.onLDAPMessage({ + errorCode: res.result.resultCode, + type: Ci.nsILDAPMessage.RES_SEARCH_RESULT, + }); + } + } + } + ); + } catch (e) { + this._listener.onLDAPError(e.result, null, ""); + } + } + + abandonExt() { + if (this._messageId) { + this._client.abandon(this._messageId); + } + } + + /** + * Decide what to do on bind success. When searching a reference url, trigger + * a new search. Otherwise, emit a message to this._listener. + * + * @param {number} errorCode - The result code of BindResponse. + */ + _onBindSuccess(errorCode) { + if (this._searchingReference) { + this.searchExt(...this._searchArgs); + } else { + this._listener.onLDAPMessage({ + errorCode, + type: Ci.nsILDAPMessage.RES_BIND, + }); + } + } + + /** + * Connect to a reference url and continue the search. + * + * @param {string} urlStr - A url string we get from SearchResultReference. + */ + _searchReference(urlStr) { + this._searchingReference = true; + let urlParser = Cc["@mozilla.org/network/ldap-url-parser;1"].createInstance( + Ci.nsILDAPURLParser + ); + let url; + try { + url = urlParser.parse(urlStr); + } catch (e) { + console.error(e); + return; + } + this._client = new lazy.LDAPClient( + url.host, + url.port, + url.options & Ci.nsILDAPURL.OPT_SECURE + ); + this._client.onOpen = () => { + if (this._password) { + this.simpleBind(this._password); + } else { + this.saslBind(...this._saslBindData); + } + }; + this._client.onError = (status, secInfo) => { + this._listener.onLDAPError(status, secInfo, `${url.host}:${url.port}`); + }; + this._client.connect(); + } +} + +LDAPOperation.prototype.classID = Components.ID( + "{a6f94ca4-cd2d-4983-bcf2-fe936190955c}" +); diff --git a/comm/mailnews/addrbook/modules/LDAPProtocolHandler.jsm b/comm/mailnews/addrbook/modules/LDAPProtocolHandler.jsm new file mode 100644 index 0000000000..c9d84f2d99 --- /dev/null +++ b/comm/mailnews/addrbook/modules/LDAPProtocolHandler.jsm @@ -0,0 +1,41 @@ +/* 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 = ["LDAPProtocolHandler", "LDAPSProtocolHandler"]; + +/** + * @implements {nsIProtocolHandler} + */ +class LDAPProtocolHandler { + QueryInterface = ChromeUtils.generateQI(["nsIProtocolHandler"]); + + scheme = "ldap"; + + newChannel(aURI, aLoadInfo) { + let channel = Cc["@mozilla.org/network/ldap-channel;1"].createInstance( + Ci.nsIChannel + ); + channel.init(aURI); + channel.loadInfo = aLoadInfo; + return channel; + } + + allowPort(port, scheme) { + return port == 389; + } +} +LDAPProtocolHandler.prototype.classID = Components.ID( + "{b3de9249-b0e5-4c12-8d91-c9a434fd80f5}" +); + +class LDAPSProtocolHandler extends LDAPProtocolHandler { + scheme = "ldaps"; + + allowPort(port, scheme) { + return port == 636; + } +} +LDAPSProtocolHandler.prototype.classID = Components.ID( + "{c85a5ef2-9c56-445f-b029-76889f2dd29b}" +); diff --git a/comm/mailnews/addrbook/modules/LDAPReplicationService.jsm b/comm/mailnews/addrbook/modules/LDAPReplicationService.jsm new file mode 100644 index 0000000000..2a11d15eee --- /dev/null +++ b/comm/mailnews/addrbook/modules/LDAPReplicationService.jsm @@ -0,0 +1,233 @@ +/* -*- Mode: JavaScript; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["LDAPReplicationService"]; + +const { LDAPListenerBase } = ChromeUtils.import( + "resource:///modules/LDAPListenerBase.jsm" +); +var { SQLiteDirectory } = ChromeUtils.import( + "resource:///modules/SQLiteDirectory.jsm" +); + +/** + * A service to replicate a LDAP directory to a local SQLite db. + * + * @implements {nsIAbLDAPReplicationService} + * @implements {nsILDAPMessageListener} + */ +class LDAPReplicationService extends LDAPListenerBase { + QueryInterface = ChromeUtils.generateQI([ + "nsIAbLDAPReplicationService", + "nsILDAPMessageListener", + ]); + + /** + * @see nsIAbLDAPReplicationService + */ + startReplication(directory, progressListener) { + this._directory = directory; + this._listener = progressListener; + this._attrMap = directory.attributeMap; + this._count = 0; + this._cards = []; + this._connection = Cc[ + "@mozilla.org/network/ldap-connection;1" + ].createInstance(Ci.nsILDAPConnection); + this._operation = Cc[ + "@mozilla.org/network/ldap-operation;1" + ].createInstance(Ci.nsILDAPOperation); + + this._connection.init( + directory.lDAPURL, + directory.authDn, + this, + null, + directory.protocolVersion + ); + } + + /** + * @see nsIAbLDAPReplicationService + */ + cancelReplication(directory) { + this._operation.abandonExt(); + this.done(false); + } + + /** + * @see nsIAbLDAPReplicationService + */ + done(success) { + this._done(success); + } + + /** + * @see nsILDAPMessageListener + */ + onLDAPMessage(msg) { + switch (msg.type) { + case Ci.nsILDAPMessage.RES_BIND: + this._onLDAPBind(msg); + break; + case Ci.nsILDAPMessage.RES_SEARCH_ENTRY: + this._onLDAPSearchEntry(msg); + break; + case Ci.nsILDAPMessage.RES_SEARCH_RESULT: + this._onLDAPSearchResult(msg); + break; + default: + break; + } + } + + /** + * @see nsILDAPMessageListener + */ + onLDAPError(status, secInfo, location) { + this.done(false); + } + + /** + * @see LDAPListenerBase + */ + _actionOnBindSuccess() { + this._openABForReplicationDir(); + let ldapUrl = this._directory.lDAPURL; + this._operation.init(this._connection, this, null); + this._listener.onStateChange( + null, + null, + Ci.nsIWebProgressListener.STATE_START, + Cr.NS_OK + ); + this._operation.searchExt( + ldapUrl.dn, + ldapUrl.scope, + ldapUrl.filter, + ldapUrl.attributes, + 0, + 0 + ); + } + + /** + * @see LDAPListenerBase + */ + _actionOnBindFailure() { + this._done(false); + } + + /** + * Handler of nsILDAPMessage.RES_SEARCH_ENTRY message. + * + * @param {nsILDAPMessage} msg - The received LDAP message. + */ + async _onLDAPSearchEntry(msg) { + let newCard = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance( + Ci.nsIAbCard + ); + this._attrMap.setCardPropertiesFromLDAPMessage(msg, newCard); + this._cards.push(newCard); + this._count++; + if (this._count % 10 == 0) { + // inform the listener every 10 entries + this._listener.onProgressChange( + null, + null, + this._count, + -1, + this._count, + -1 + ); + } + if (this._count % 100 == 0 && !this._writePromise) { + // Write to the db to release some memory. + this._writePromise = this._replicationDB.bulkAddCards(this._cards); + this._cards = []; + await this._writePromise; + this._writePromise = null; + } + } + + /** + * Handler of nsILDAPMessage.RES_SEARCH_RESULT message. + * + * @param {nsILDAPMessage} msg - The received LDAP message. + */ + async _onLDAPSearchResult(msg) { + if ( + msg.errorCode == Ci.nsILDAPErrors.SUCCESS || + msg.errorCode == Ci.nsILDAPErrors.SIZELIMIT_EXCEEDED + ) { + if (this._writePromise) { + await this._writePromise; + } + await this._replicationDB.bulkAddCards(this._cards); + this.done(true); + return; + } + this.done(false); + } + + /** + * Init a jsaddrbook from the replicationFileName of the current LDAP directory. + */ + _openABForReplicationDir() { + this._oldReplicationFileName = this._directory.replicationFileName; + this._replicationFile = this._directory.replicationFile; + if (this._replicationFile.exists()) { + // If the database file already exists, create a new one here, and replace + // the old file in _done when success. + this._replicationFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600); + // What we need is the unique file name, _replicationDB will create an + // empty database file. + this._replicationFile.remove(false); + // Set replicationFileName to the new db file name, so that _replicationDB + // works correctly. + this._directory.replicationFileName = this._replicationFile.leafName; + } + + this._replicationDB = new SQLiteDirectory(); + this._replicationDB.init(`jsaddrbook://${this._replicationFile.leafName}`); + } + + /** + * Clean up depending on whether replication succeeded or failed, emit + * STATE_STOP event. + * + * @param {bool} success - Replication succeeded or failed. + */ + async _done(success) { + this._cards = []; + if (this._replicationDB) { + // Close the db. + await this._replicationDB.cleanUp(); + } + if (success) { + // Replace the old db file with new db file. + this._replicationFile.moveTo(null, this._oldReplicationFileName); + } else if ( + this._replicationFile && + this._replicationFile.path != this._oldReplicationFileName + ) { + this._replicationFile.remove(false); + } + if (this._oldReplicationFileName) { + // Reset replicationFileName to the old db file name. + this._directory.replicationFileName = this._oldReplicationFileName; + } + this._listener.onStateChange( + null, + null, + Ci.nsIWebProgressListener.STATE_STOP, + success ? Cr.NS_OK : Cr.NS_ERROR_FAILURE + ); + } +} + +LDAPReplicationService.prototype.classID = Components.ID( + "{dbe204e8-ae09-11eb-b4c8-a7e4b3e6e82e}" +); diff --git a/comm/mailnews/addrbook/modules/LDAPService.jsm b/comm/mailnews/addrbook/modules/LDAPService.jsm new file mode 100644 index 0000000000..d1def67afc --- /dev/null +++ b/comm/mailnews/addrbook/modules/LDAPService.jsm @@ -0,0 +1,66 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["LDAPService"]; + +/** + * @implements {nsILDAPService} + */ +class LDAPService { + QueryInterface = ChromeUtils.generateQI(["nsILDAPService"]); + + createFilter(maxSize, pattern, prefix, suffix, attr, value) { + let words = value.split(" "); + // Get the Mth to Nth words. + function getMtoN(m, n) { + n = n || m; + return words.slice(m - 1, n).join(" "); + } + + let filter = prefix; + pattern.replaceAll("%a", attr); + while (pattern) { + let index = pattern.indexOf("%v"); + if (index == -1) { + filter += pattern; + pattern = ""; + } else { + filter += pattern.slice(0, index); + // Get the three characters after %v. + let [c1, c2, c3] = pattern.slice(index + 2, index + 5); + if (c1 >= "1" && c1 <= "9") { + if (c2 == "$") { + // %v$: means the last word + filter += getMtoN(words.length); + pattern = pattern.slice(index + 3); + } else if (c2 == "-") { + if (c3 >= "1" && c3 <= "9") { + // %vM-N: means from the Mth to the Nth word + filter += getMtoN(c1, c3); + pattern = pattern.slice(index + 5); + } else { + // %vN-: means from the Nth to the last word + filter += getMtoN(c1, words.length); + pattern = pattern.slice(index + 4); + } + } else { + // %vN: means the Nth word + filter += getMtoN(c1); + pattern = pattern.slice(index + 3); + } + } else { + // %v: means the entire search value + filter += value; + pattern = pattern.slice(index + 2); + } + } + } + filter += suffix; + return filter.length > maxSize ? "" : filter; + } +} + +LDAPService.prototype.classID = Components.ID( + "{e8b59b32-f83f-4d5f-8eb5-e3c1e5de0d47}" +); diff --git a/comm/mailnews/addrbook/modules/LDAPSyncQuery.jsm b/comm/mailnews/addrbook/modules/LDAPSyncQuery.jsm new file mode 100644 index 0000000000..d92fea191f --- /dev/null +++ b/comm/mailnews/addrbook/modules/LDAPSyncQuery.jsm @@ -0,0 +1,112 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["LDAPSyncQuery"]; + +/** + * @implements {nsILDAPMessageListener} + * @implements {nsILDAPSyncQuery} + */ +class LDAPSyncQuery { + QueryInterface = ChromeUtils.generateQI([ + "nsILDAPMessageListener", + "nsILDAPSyncQuery", + ]); + + /** @see nsILDAPMessageListener */ + onLDAPInit() { + this._operation = Cc[ + "@mozilla.org/network/ldap-operation;1" + ].createInstance(Ci.nsILDAPOperation); + this._operation.init(this._connection, this, null); + this._operation.simpleBind(""); + } + + onLDAPMessage(msg) { + switch (msg.type) { + case Ci.nsILDAPMessage.RES_BIND: + this._onLDAPBind(msg); + break; + case Ci.nsILDAPMessage.RES_SEARCH_ENTRY: + this._onLDAPSearchEntry(msg); + break; + case Ci.nsILDAPMessage.RES_SEARCH_RESULT: + this._onLDAPSearchResult(msg); + break; + default: + break; + } + } + + onLDAPError(status, secInfo, location) { + this._statusCode = status; + this._finished = true; + } + + /** @see nsILDAPSyncQuery */ + getQueryResults(ldapUrl, protocolVersion) { + this._ldapUrl = ldapUrl; + this._connection = Cc[ + "@mozilla.org/network/ldap-connection;1" + ].createInstance(Ci.nsILDAPConnection); + this._connection.init(ldapUrl, "", this, null, protocolVersion); + + this._statusCode = 0; + this._result = ""; + this._finished = false; + + Services.tm.spinEventLoopUntil( + "getQueryResults is a sync function", + () => this._finished + ); + if (this._statusCode) { + throw Components.Exception("getQueryResults failed", this._statusCode); + } + return this._result; + } + + /** + * Handler of nsILDAPMessage.RES_BIND message. + * + * @param {nsILDAPMessage} msg - The received LDAP message. + */ + _onLDAPBind(msg) { + if (msg.errorCode != Ci.nsILDAPErrors.SUCCESS) { + this._statusCode = msg.errorCode; + this._finished = true; + return; + } + this._operation.init(this._connection, this, null); + this._operation.searchExt( + this._ldapUrl.dn, + this._ldapUrl.scope, + this._ldapUrl.filter, + this._ldapUrl.attributes, + 0, + 0 + ); + } + + /** + * Handler of nsILDAPMessage.RES_SEARCH_ENTRY message. + * + * @param {nsILDAPMessage} msg - The received LDAP message. + */ + _onLDAPSearchEntry(msg) { + for (let attr of msg.getAttributes()) { + for (let value of msg.getValues(attr)) { + this._result += `\n${attr}=${value}`; + } + } + } + + /** + * Handler of nsILDAPMessage.RES_SEARCH_RESULT message. + * + * @param {nsILDAPMessage} msg - The received LDAP message. + */ + _onLDAPSearchResult(msg) { + this._finished = true; + } +} diff --git a/comm/mailnews/addrbook/modules/LDAPURLParser.jsm b/comm/mailnews/addrbook/modules/LDAPURLParser.jsm new file mode 100644 index 0000000000..2c19be1386 --- /dev/null +++ b/comm/mailnews/addrbook/modules/LDAPURLParser.jsm @@ -0,0 +1,42 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["LDAPURLParser"]; + +/** + * @implements {nsILDAPURLParser} + */ +class LDAPURLParser { + QueryInterface = ChromeUtils.generateQI(["nsILDAPURLParser"]); + + parse(spec) { + // The url is in the form of scheme://hostport/dn?attributes?scope?filter, + // see RFC2255. + let matches = + /^(ldaps?):\/\/\[?([^\s\]/]+)\]?:?(\d*)\/([^\s?]*)\??(.*)$/.exec(spec); + if (!matches) { + throw Components.Exception( + `Invalid LDAP URL: ${spec}`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + let [, scheme, host, port, dn, query] = matches; + let [attributes, scopeString, filter] = query.split("?"); + let scope = + { + one: Ci.nsILDAPURL.SCOPE_ONELEVEL, + sub: Ci.nsILDAPURL.SCOPE_SUBTREE, + }[scopeString] || Ci.nsILDAPURL.SCOPE_BASE; + return { + QueryInterface: ChromeUtils.generateQI(["nsILDAPURLParserResult"]), + host, + port, + dn: decodeURIComponent(dn), + attributes, + scope, + filter: filter ? decodeURIComponent(filter) : "(objectclass=*)", + options: scheme == "ldaps" ? Ci.nsILDAPURL.OPT_SECURE : 0, + }; + } +} diff --git a/comm/mailnews/addrbook/modules/QueryStringToExpression.jsm b/comm/mailnews/addrbook/modules/QueryStringToExpression.jsm new file mode 100644 index 0000000000..0129d2e3d3 --- /dev/null +++ b/comm/mailnews/addrbook/modules/QueryStringToExpression.jsm @@ -0,0 +1,186 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["QueryStringToExpression"]; + +/** + * A module to parse a query string to a nsIAbBooleanExpression. A valid query + * string is in this form: + * + * (OP1(FIELD1,COND1,VALUE1)..(FIELDn,CONDn,VALUEn)(BOOL2(FIELD1,COND1,VALUE1)..)..) + * + * OPn A boolean operator joining subsequent terms delimited by (). + * + * @see {nsIAbBooleanOperationTypes}. + * FIELDn An addressbook card data field. + * CONDn A condition to compare FIELDn with VALUEn. + * @see {nsIAbBooleanConditionTypes}. + * VALUEn The value to be matched in the FIELDn via the CONDn. + * The value must be URL encoded by the caller, if it contains any + * special characters including '(' and ')'. + */ +var QueryStringToExpression = { + /** + * Convert a query string to a nsIAbBooleanExpression. + * + * @param {string} qs - The query string to convert. + * @returns {nsIAbBooleanExpression} + */ + convert(qs) { + let tokens = this.parse(qs); + + // An array of nsIAbBooleanExpression, the first element is the root exp, + // the last element is the current operating exp. + let stack = []; + for (let { type, depth, value } of tokens) { + while (depth < stack.length) { + // We are done with the current exp, go one level up. + stack.pop(); + } + if (type == "op") { + if (depth == stack.length) { + // We are done with the current exp, go one level up. + stack.pop(); + } + // Found a new exp, go one level down. + let parent = stack.slice(-1)[0]; + let exp = this.createBooleanExpression(value); + stack.push(exp); + if (parent) { + parent.expressions = [...parent.expressions, exp]; + } + } else if (type == "field") { + // Add a new nsIAbBooleanConditionString to the current exp. + let condition = this.createBooleanConditionString(...value); + let exp = stack.slice(-1)[0]; + exp.expressions = [...exp.expressions, condition]; + } + } + + return stack[0]; + }, + + /** + * Parse a query string to an array of tokens. + * + * @param {string} qs - The query string to parse. + * @param {number} depth - The depth of a token. + * @param {object[]} tokens - The tokens to return. + * @param {"op"|"field"} tokens[].type - The token type. + * @param {number} tokens[].depth - The token depth. + * @param {string|string[]} tokens[].value - The token value. + */ + parse(qs, depth = 0, tokens = []) { + if (qs[0] == "?") { + qs = qs.slice(1); + } + while (qs[0] == ")" && depth > 0) { + depth--; + qs = qs.slice(1); + } + if (qs.length == 0) { + // End of input. + return tokens; + } + if (qs[0] != "(") { + throw Components.Exception( + `Invalid query string: ${qs}`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + qs = qs.slice(1); + let nextOpen = qs.indexOf("("); + let nextClose = qs.indexOf(")"); + + if (nextOpen != -1 && nextOpen < nextClose) { + // Case: "OP(" + depth++; + tokens.push({ + type: "op", + depth, + value: qs.slice(0, nextOpen), + }); + this.parse(qs.slice(nextOpen), depth, tokens); + } else if (nextClose != -1) { + // Case: "FIELD, COND, VALUE)" + tokens.push({ + type: "field", + depth, + value: qs.slice(0, nextClose).split(","), + }); + this.parse(qs.slice(nextClose + 1), depth, tokens); + } + return tokens; + }, + + /** + * Create a nsIAbBooleanExpression from a string. + * + * @param {string} operation - The operation string. + * @returns {nsIAbBooleanExpression} + */ + createBooleanExpression(operation) { + let op = { + and: Ci.nsIAbBooleanOperationTypes.AND, + or: Ci.nsIAbBooleanOperationTypes.OR, + not: Ci.nsIAbBooleanOperationTypes.NOT, + }[operation]; + if (op == undefined) { + throw Components.Exception( + `Invalid operation: ${operation}`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + let exp = Cc["@mozilla.org/boolean-expression/n-peer;1"].createInstance( + Ci.nsIAbBooleanExpression + ); + exp.operation = op; + return exp; + }, + + /** + * Create a nsIAbBooleanConditionString. + * + * @param {string} name - The field name. + * @param {nsIAbBooleanConditionTypes} condition - The condition. + * @param {string} value - The value string. + * @returns {nsIAbBooleanConditionString} + */ + createBooleanConditionString(name, condition, value) { + value = decodeURIComponent(value); + let cond = { + "=": Ci.nsIAbBooleanConditionTypes.Is, + "!=": Ci.nsIAbBooleanConditionTypes.IsNot, + lt: Ci.nsIAbBooleanConditionTypes.LessThan, + gt: Ci.nsIAbBooleanConditionTypes.GreaterThan, + bw: Ci.nsIAbBooleanConditionTypes.BeginsWith, + ew: Ci.nsIAbBooleanConditionTypes.EndsWith, + c: Ci.nsIAbBooleanConditionTypes.Contains, + "!c": Ci.nsIAbBooleanConditionTypes.DoesNotContain, + "~=": Ci.nsIAbBooleanConditionTypes.SoundsLike, + regex: Ci.nsIAbBooleanConditionTypes.RegExp, + ex: Ci.nsIAbBooleanConditionTypes.Exists, + "!ex": Ci.nsIAbBooleanConditionTypes.DoesNotExist, + }[condition]; + if (name == "" || condition == "" || value == "" || cond == undefined) { + throw Components.Exception( + `Failed to create condition string from name=${name}, condition=${condition}, value=${value}, cond=${cond}`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + let cs = Cc[ + "@mozilla.org/boolean-expression/condition-string;1" + ].createInstance(Ci.nsIAbBooleanConditionString); + cs.condition = cond; + + try { + cs.name = Services.textToSubURI.unEscapeAndConvert("UTF-8", name); + cs.value = Services.textToSubURI.unEscapeAndConvert("UTF-8", value); + } catch (e) { + cs.name = name; + cs.value = value; + } + return cs; + }, +}; diff --git a/comm/mailnews/addrbook/modules/SQLiteDirectory.jsm b/comm/mailnews/addrbook/modules/SQLiteDirectory.jsm new file mode 100644 index 0000000000..a89f2880d7 --- /dev/null +++ b/comm/mailnews/addrbook/modules/SQLiteDirectory.jsm @@ -0,0 +1,474 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = ["SQLiteDirectory"]; + +const { AddrBookDirectory } = ChromeUtils.import( + "resource:///modules/AddrBookDirectory.jsm" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +const { AsyncShutdown } = ChromeUtils.importESModule( + "resource://gre/modules/AsyncShutdown.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + newUID: "resource:///modules/AddrBookUtils.jsm", +}); + +var log = console.createInstance({ + prefix: "mail.addr_book", + maxLogLevel: "Warn", + maxLogLevelPref: "mail.addr_book.loglevel", +}); + +// Track all directories by filename, for SQLiteDirectory.forFile. +var directories = new Map(); + +// Keep track of all database connections, and close them at shutdown, since +// nothing else ever tells us to close them. +var connections = new Map(); + +/** + * Opens an SQLite connection to `file`, caches the connection, and upgrades + * the database schema if necessary. + */ +function openConnectionTo(file) { + const CURRENT_VERSION = 4; + + let connection = connections.get(file.path); + if (!connection) { + connection = Services.storage.openDatabase(file); + let fileVersion = connection.schemaVersion; + + // If we're upgrading the version, first create a backup. + if (fileVersion > 0 && fileVersion < CURRENT_VERSION) { + let backupFile = file.clone(); + backupFile.leafName = backupFile.leafName.replace( + /\.sqlite$/, + `.v${fileVersion}.sqlite` + ); + backupFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644); + + log.warn(`Backing up ${file.leafName} to ${backupFile.leafName}`); + file.copyTo(null, backupFile.leafName); + } + + switch (fileVersion) { + case 0: + connection.executeSimpleSQL("PRAGMA journal_mode=WAL"); + connection.executeSimpleSQL( + "CREATE TABLE properties (card TEXT, name TEXT, value TEXT)" + ); + connection.executeSimpleSQL( + "CREATE TABLE lists (uid TEXT PRIMARY KEY, name TEXT, nickName TEXT, description TEXT)" + ); + connection.executeSimpleSQL( + "CREATE TABLE list_cards (list TEXT, card TEXT, PRIMARY KEY(list, card))" + ); + // Falls through. + case 1: + connection.executeSimpleSQL( + "CREATE INDEX properties_card ON properties(card)" + ); + connection.executeSimpleSQL( + "CREATE INDEX properties_name ON properties(name)" + ); + // Falls through. + case 2: + connection.executeSimpleSQL("DROP TABLE IF EXISTS cards"); + // The lists table may have a localId column we no longer use, but + // since SQLite can't drop columns it's not worth effort to remove it. + // Falls through. + case 3: + // This version exists only to create an automatic backup before cards + // are transitioned to vCard. + connection.schemaVersion = CURRENT_VERSION; + break; + } + connections.set(file.path, connection); + } + return connection; +} + +/** + * Closes the SQLite connection to `file` and removes it from the cache. + */ +function closeConnectionTo(file) { + let connection = connections.get(file.path); + if (connection) { + return new Promise(resolve => { + connection.asyncClose({ + complete() { + resolve(); + }, + }); + connections.delete(file.path); + }); + } + return Promise.resolve(); +} + +// Close all open connections at shut down time. +AsyncShutdown.profileBeforeChange.addBlocker( + "Address Book: closing databases", + async () => { + let promises = []; + for (let directory of directories.values()) { + promises.push(directory.cleanUp()); + } + await Promise.allSettled(promises); + } +); + +// Close a connection on demand. This serves as an escape hatch from C++ code. +Services.obs.addObserver(async file => { + file.QueryInterface(Ci.nsIFile); + await closeConnectionTo(file); + Services.obs.notifyObservers(file, "addrbook-close-ab-complete"); +}, "addrbook-close-ab"); + +/** + * Adds SQLite storage to AddrBookDirectory. + */ +class SQLiteDirectory extends AddrBookDirectory { + init(uri) { + let uriParts = /^[\w-]+:\/\/([\w\.-]+\.\w+)$/.exec(uri); + if (!uriParts) { + throw new Components.Exception( + `Unexpected uri: ${uri}`, + Cr.NS_ERROR_UNEXPECTED + ); + } + + this._uri = uri; + let fileName = uriParts[1]; + if (fileName.includes("/")) { + fileName = fileName.substring(0, fileName.indexOf("/")); + } + + for (let child of Services.prefs.getChildList("ldap_2.servers.")) { + if ( + child.endsWith(".filename") && + Services.prefs.getStringPref(child) == fileName + ) { + this._dirPrefId = child.substring(0, child.length - ".filename".length); + break; + } + } + if (!this._dirPrefId) { + throw Components.Exception( + `Couldn't grab dirPrefId for uri=${uri}, fileName=${fileName}`, + Cr.NS_ERROR_UNEXPECTED + ); + } + + // Make sure we always have a file. If a file is not created, the + // filename may be accidentally reused. + let file = lazy.FileUtils.getFile("ProfD", [fileName]); + if (!file.exists()) { + file.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644); + } + + this._fileName = fileName; + + super.init(uri); + + directories.set(fileName, this); + // Create the DB connection here already, to let init() throw on corrupt SQLite files. + this._dbConnection; + } + async cleanUp() { + await super.cleanUp(); + + if (this.hasOwnProperty("_file")) { + await closeConnectionTo(this._file); + delete this._file; + } + + directories.delete(this._fileName); + } + + get _dbConnection() { + this._file = lazy.FileUtils.getFile("ProfD", [this.fileName]); + let connection = openConnectionTo(this._file); + + // SQLite cache size can be set by the cacheSize preference, in KiB. + // The default is 5 MiB but this can be lowered to 1 MiB if wanted. + // There is no maximum size. + let cacheSize = this.getIntValue("cacheSize", 5120); // 5 MiB + cacheSize = Math.max(cacheSize, 1024); // 1 MiB + connection.executeSimpleSQL(`PRAGMA cache_size=-${cacheSize}`); + + Object.defineProperty(this, "_dbConnection", { + enumerable: true, + value: connection, + writable: false, + }); + return connection; + } + get lists() { + let listCache = new Map(); + let selectStatement = this._dbConnection.createStatement( + "SELECT uid, name, nickName, description FROM lists" + ); + while (selectStatement.executeStep()) { + listCache.set(selectStatement.row.uid, { + uid: selectStatement.row.uid, + name: selectStatement.row.name, + nickName: selectStatement.row.nickName, + description: selectStatement.row.description, + }); + } + selectStatement.finalize(); + + Object.defineProperty(this, "lists", { + enumerable: true, + value: listCache, + writable: false, + }); + return listCache; + } + get cards() { + let cardCache = new Map(); + let propertiesStatement = this._dbConnection.createStatement( + "SELECT card, name, value FROM properties" + ); + while (propertiesStatement.executeStep()) { + let uid = propertiesStatement.row.card; + if (!cardCache.has(uid)) { + cardCache.set(uid, new Map()); + } + let card = cardCache.get(uid); + if (card) { + card.set(propertiesStatement.row.name, propertiesStatement.row.value); + } + } + propertiesStatement.finalize(); + + Object.defineProperty(this, "cards", { + enumerable: true, + value: cardCache, + writable: false, + }); + return cardCache; + } + + loadCardProperties(uid) { + if (this.hasOwnProperty("cards")) { + let cachedCard = this.cards.get(uid); + if (cachedCard) { + return new Map(cachedCard); + } + } + let properties = new Map(); + let propertyStatement = this._dbConnection.createStatement( + "SELECT name, value FROM properties WHERE card = :card" + ); + propertyStatement.params.card = uid; + while (propertyStatement.executeStep()) { + properties.set(propertyStatement.row.name, propertyStatement.row.value); + } + propertyStatement.finalize(); + return properties; + } + saveCardProperties(uid, properties) { + try { + this._dbConnection.beginTransaction(); + let deleteStatement = this._dbConnection.createStatement( + "DELETE FROM properties WHERE card = :card" + ); + deleteStatement.params.card = uid; + deleteStatement.execute(); + let insertStatement = this._dbConnection.createStatement( + "INSERT INTO properties VALUES (:card, :name, :value)" + ); + + for (let [name, value] of properties) { + if (value !== null && value !== undefined && value !== "") { + insertStatement.params.card = uid; + insertStatement.params.name = name; + insertStatement.params.value = value; + insertStatement.execute(); + insertStatement.reset(); + } + } + + this._dbConnection.commitTransaction(); + deleteStatement.finalize(); + insertStatement.finalize(); + } catch (ex) { + this._dbConnection.rollbackTransaction(); + throw ex; + } + } + deleteCard(uid) { + let deleteStatement = this._dbConnection.createStatement( + "DELETE FROM properties WHERE card = :cardUID" + ); + deleteStatement.params.cardUID = uid; + deleteStatement.execute(); + deleteStatement.finalize(); + } + saveList(list) { + // Ensure list cache exists. + this.lists; + + let replaceStatement = this._dbConnection.createStatement( + "REPLACE INTO lists (uid, name, nickName, description) " + + "VALUES (:uid, :name, :nickName, :description)" + ); + replaceStatement.params.uid = list._uid; + replaceStatement.params.name = list._name; + replaceStatement.params.nickName = list._nickName; + replaceStatement.params.description = list._description; + replaceStatement.execute(); + replaceStatement.finalize(); + + this.lists.set(list._uid, { + uid: list._uid, + name: list._name, + nickName: list._nickName, + description: list._description, + }); + } + deleteList(uid) { + let deleteListStatement = this._dbConnection.createStatement( + "DELETE FROM lists WHERE uid = :uid" + ); + deleteListStatement.params.uid = uid; + deleteListStatement.execute(); + deleteListStatement.finalize(); + + if (this.hasOwnProperty("lists")) { + this.lists.delete(uid); + } + + this._dbConnection.executeSimpleSQL( + "DELETE FROM list_cards WHERE list NOT IN (SELECT DISTINCT uid FROM lists)" + ); + } + async bulkAddCards(cards) { + if (cards.length == 0) { + return; + } + + let usedUIDs = new Set(); + let propertiesStatement = this._dbConnection.createStatement( + "INSERT INTO properties VALUES (:card, :name, :value)" + ); + let propertiesArray = propertiesStatement.newBindingParamsArray(); + for (let card of cards) { + let uid = card.UID; + if (!uid || usedUIDs.has(uid)) { + // A card cannot have the same UID as one that already exists. + // Assign a new UID to avoid losing data. + uid = lazy.newUID(); + } + usedUIDs.add(uid); + + let cachedCard; + if (this.hasOwnProperty("cards")) { + cachedCard = new Map(); + this.cards.set(uid, cachedCard); + } + + for (let [name, value] of this.prepareToSaveCard(card)) { + let propertiesParams = propertiesArray.newBindingParams(); + propertiesParams.bindByName("card", uid); + propertiesParams.bindByName("name", name); + propertiesParams.bindByName("value", value); + propertiesArray.addParams(propertiesParams); + + if (cachedCard) { + cachedCard.set(name, value); + } + } + } + try { + this._dbConnection.beginTransaction(); + if (propertiesArray.length > 0) { + propertiesStatement.bindParameters(propertiesArray); + await new Promise((resolve, reject) => { + propertiesStatement.executeAsync({ + handleError(error) { + this._error = error; + }, + handleCompletion(status) { + if (status == Ci.mozIStorageStatementCallback.REASON_ERROR) { + reject( + Components.Exception(this._error.message, Cr.NS_ERROR_FAILURE) + ); + } else { + resolve(); + } + }, + }); + }); + propertiesStatement.finalize(); + } + this._dbConnection.commitTransaction(); + + Services.obs.notifyObservers(this, "addrbook-directory-invalidated"); + } catch (ex) { + this._dbConnection.rollbackTransaction(); + throw ex; + } + } + + /* nsIAbDirectory */ + + get childCardCount() { + let countStatement = this._dbConnection.createStatement( + "SELECT COUNT(DISTINCT card) AS card_count FROM properties" + ); + countStatement.executeStep(); + let count = countStatement.row.card_count; + countStatement.finalize(); + return count; + } + getCardFromProperty(property, value, caseSensitive) { + let sql = caseSensitive + ? "SELECT card FROM properties WHERE name = :name AND value = :value LIMIT 1" + : "SELECT card FROM properties WHERE name = :name AND LOWER(value) = LOWER(:value) LIMIT 1"; + let selectStatement = this._dbConnection.createStatement(sql); + selectStatement.params.name = property; + selectStatement.params.value = value; + let result = null; + if (selectStatement.executeStep()) { + result = this.getCard(selectStatement.row.card); + } + selectStatement.finalize(); + return result; + } + getCardsFromProperty(property, value, caseSensitive) { + let sql = caseSensitive + ? "SELECT card FROM properties WHERE name = :name AND value = :value" + : "SELECT card FROM properties WHERE name = :name AND LOWER(value) = LOWER(:value)"; + let selectStatement = this._dbConnection.createStatement(sql); + selectStatement.params.name = property; + selectStatement.params.value = value; + let results = []; + while (selectStatement.executeStep()) { + results.push(this.getCard(selectStatement.row.card)); + } + selectStatement.finalize(); + return results; + } + + static forFile(fileName) { + return directories.get(fileName); + } +} +SQLiteDirectory.prototype.classID = Components.ID( + "{e96ee804-0bd3-472f-81a6-8a9d65277ad3}" +); diff --git a/comm/mailnews/addrbook/modules/VCardUtils.jsm b/comm/mailnews/addrbook/modules/VCardUtils.jsm new file mode 100644 index 0000000000..a3ff0f5e14 --- /dev/null +++ b/comm/mailnews/addrbook/modules/VCardUtils.jsm @@ -0,0 +1,973 @@ +/* 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/. */ + +const EXPORTED_SYMBOLS = [ + "VCardService", + "VCardMimeConverter", + "VCardProperties", + "VCardPropertyEntry", + "VCardUtils", + "BANISHED_PROPERTIES", +]; + +const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm"); + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + AddrBookCard: "resource:///modules/AddrBookCard.jsm", +}); + +/** + * Utilities for working with vCard data. This file uses ICAL.js as parser and + * formatter to avoid reinventing the wheel. + * + * @see RFC 6350. + */ + +var VCardUtils = { + _decodeQuotedPrintable(value) { + let bytes = []; + for (let b = 0; b < value.length; b++) { + if (value[b] == "=") { + bytes.push(parseInt(value.substr(b + 1, 2), 16)); + b += 2; + } else { + bytes.push(value.charCodeAt(b)); + } + } + return new TextDecoder().decode(new Uint8Array(bytes)); + }, + _parse(vProps) { + let vPropMap = new Map(); + for (let index = 0; index < vProps.length; index++) { + let { name, params, value } = vProps[index]; + + // Work out which type in typeMap, if any, this property belongs to. + + // To make the next piece easier, the type param must always be an array + // of lower-case strings. + let type = params.type || []; + if (type) { + if (Array.isArray(type)) { + type = type.map(t => t.toLowerCase()); + } else { + type = [type.toLowerCase()]; + } + } + + // Special cases for address and telephone types. + if (name == "adr") { + name = type.includes("home") ? "adr.home" : "adr.work"; + } + if (name == "tel") { + name = "tel.work"; + for (let t of type) { + if (["home", "work", "cell", "pager", "fax"].includes(t)) { + name = `tel.${t}`; + break; + } + } + } + // Preserve URL if no URL with type work is given take for `url.work` the URL without any type. + if (name == "url") { + name = type.includes("home") ? "url.home" : name; + name = type.includes("work") ? "url.work" : name; + } + + // Special treatment for `url`, which is not in the typeMap. + if (!(name in typeMap) && name != "url") { + continue; + } + + // The preference param is 1-100, lower numbers indicate higher + // preference. If not specified, the value is least preferred. + let pref = parseInt(params.pref, 10) || 101; + + if (!vPropMap.has(name)) { + vPropMap.set(name, []); + } + vPropMap.get(name).push({ index, pref, value }); + } + + // If no URL with type is specified assume its the Work Web Page (WebPage 1). + if (vPropMap.has("url") && !vPropMap.has("url.work")) { + vPropMap.set("url.work", vPropMap.get("url")); + } + // AbCard only supports Work Web Page or Home Web Page. Get rid of the URL without type. + vPropMap.delete("url"); + + for (let props of vPropMap.values()) { + // Sort the properties by preference, or by the order they appeared. + props.sort((a, b) => { + if (a.pref == b.pref) { + return a.index - b.index; + } + return a.pref - b.pref; + }); + } + return vPropMap; + }, + /** + * ICAL.js's parser only supports vCard 3.0 and 4.0. To maintain + * interoperability with other applications, here we convert vCard 2.1 + * cards into a "good-enough" mimic of vCard 4.0 so that the parser will + * read it without throwing an error. + * + * @param {string} vCard + * @returns {string} + */ + translateVCard21(vCard) { + if (!/\bVERSION:2.1\b/i.test(vCard)) { + return vCard; + } + + // Convert known type parameters to valid vCard 4.0, ignore unknown ones. + vCard = vCard.replace(/\n(([A-Z]+)(;[\w-]*)+):/gi, (match, key) => { + let parts = key.split(";"); + let newParts = [parts[0]]; + for (let i = 1; i < parts.length; i++) { + if (parts[i] == "") { + continue; + } + if ( + ["HOME", "WORK", "FAX", "PAGER", "CELL"].includes( + parts[i].toUpperCase() + ) + ) { + newParts.push(`TYPE=${parts[i]}`); + } else if (parts[i].toUpperCase() == "PREF") { + newParts.push("PREF=1"); + } else if (parts[i].toUpperCase() == "QUOTED-PRINTABLE") { + newParts.push("ENCODING=QUOTED-PRINTABLE"); + } + } + return "\n" + newParts.join(";") + ":"; + }); + + // Join quoted-printable wrapped lines together. This regular expression + // only matches lines that are quoted-printable and end with `=`. + let quotedNewLineRegExp = /(;ENCODING=QUOTED-PRINTABLE[;:][^\r\n]*)=\r?\n/i; + while (vCard.match(quotedNewLineRegExp)) { + vCard = vCard.replace(quotedNewLineRegExp, "$1"); + } + + // Strip the version. + return vCard.replace(/(\r?\n)VERSION:2.1\r?\n/i, "$1"); + }, + /** + * Return a new AddrBookCard from the provided vCard string. + * + * @param {string} vCard - The vCard string. + * @param {string} [uid] - An optional UID to be used for the new card, + * overriding any UID specified in the vCard string. + * @returns {AddrBookCard} + */ + vCardToAbCard(vCard, uid) { + vCard = this.translateVCard21(vCard); + + let abCard = new lazy.AddrBookCard(); + abCard.setProperty("_vCard", vCard); + + let vCardUID = abCard.vCardProperties.getFirstValue("uid"); + if (uid || vCardUID) { + abCard.UID = uid || vCardUID; + if (abCard.UID != vCardUID) { + abCard.vCardProperties.clearValues("uid"); + abCard.vCardProperties.addValue("uid", abCard.UID); + } + } + + return abCard; + }, + abCardToVCard(abCard, version) { + if (abCard.supportsVCard && abCard.getProperty("_vCard")) { + return abCard.vCardProperties.toVCard(); + } + + // Collect all of the AB card properties into a Map. + let abProps = new Map( + Array.from(abCard.properties, p => [p.name, p.value]) + ); + abProps.set("UID", abCard.UID); + + return this.propertyMapToVCard(abProps, version); + }, + propertyMapToVCard(abProps, version = "4.0") { + let vProps = [["version", {}, "text", version]]; + + // Add the properties to the vCard. + for (let vPropName of Object.keys(typeMap)) { + for (let vProp of typeMap[vPropName].fromAbCard(abProps, vPropName)) { + if (vProp[3] !== null && vProp[3] !== undefined && vProp[3] !== "") { + vProps.push(vProp); + } + } + } + + // If there's only one address or telephone number, don't specify type. + let adrProps = vProps.filter(p => p[0] == "adr"); + if (adrProps.length == 1) { + delete adrProps[0][1].type; + } + let telProps = vProps.filter(p => p[0] == "tel"); + if (telProps.length == 1) { + delete telProps[0][1].type; + } + + if (abProps.has("UID")) { + vProps.push(["uid", {}, "text", abProps.get("UID")]); + } + return ICAL.stringify(["vcard", vProps]); + }, +}; + +function VCardService() {} +VCardService.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIMsgVCardService"]), + classID: Components.ID("{e2e0f615-bc5a-4441-a16b-a26e75949376}"), + + vCardToAbCard(vCard) { + return vCard ? VCardUtils.vCardToAbCard(vCard) : null; + }, + escapedVCardToAbCard(vCard) { + return vCard ? VCardUtils.vCardToAbCard(decodeURIComponent(vCard)) : null; + }, + abCardToEscapedVCard(abCard) { + return abCard ? encodeURIComponent(VCardUtils.abCardToVCard(abCard)) : null; + }, +}; + +function VCardMimeConverter() {} +VCardMimeConverter.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsISimpleMimeConverter"]), + classID: Components.ID("{dafab386-bd4c-4238-bb48-228fbc98ba29}"), + + mailChannel: null, + uri: null, + convertToHTML(contentType, data) { + function escapeHTML(template, ...parts) { + let arr = []; + for (let i = 0; i < parts.length; i++) { + arr.push(template[i]); + arr.push( + parts[i] + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/</g, "<") + .replace(/>/g, ">") + ); + } + arr.push(template[template.length - 1]); + return arr.join(""); + } + + let abCard; + try { + abCard = VCardUtils.vCardToAbCard(data); + } catch (e) { + // We were given invalid vcard data. + return ""; + } + + let escapedVCard = encodeURIComponent(data); + + let propertiesTable = `<table class="moz-vcard-properties-table">`; + propertiesTable += escapeHTML`<tr><td class="moz-vcard-title-property">${abCard.displayName}`; + if (abCard.primaryEmail) { + propertiesTable += escapeHTML` <<a href="mailto:${abCard.primaryEmail}" private>${abCard.primaryEmail}</a>>`; + } + propertiesTable += `</td></tr>`; + for (let propName of ["JobTitle", "Department", "Company"]) { + let propValue = abCard.getProperty(propName, ""); + if (propValue) { + propertiesTable += escapeHTML`<tr><td class="moz-vcard-property">${propValue}</td></tr>`; + } + } + propertiesTable += `</table>`; + + // VCardChild.jsm and VCardParent.jsm handle clicking on this link. + return `<html> + <body> + <table class="moz-vcard-table"> + <tr> + <td valign="top"><a class="moz-vcard-badge" href="data:text/vcard,${escapedVCard}"></a></td> + <td> + ${propertiesTable} + </td> + </tr> + </table> + </body> + </html>`; + }, +}; + +const BANISHED_PROPERTIES = [ + "UID", + "PrimaryEmail", + "SecondEmail", + "DisplayName", + "NickName", + "Notes", + "Company", + "Department", + "JobTitle", + "BirthDay", + "BirthMonth", + "BirthYear", + "AnniversaryDay", + "AnniversaryMonth", + "AnniversaryYear", + "LastName", + "FirstName", + "AdditionalNames", + "NamePrefix", + "NameSuffix", + "HomePOBox", + "HomeAddress2", + "HomeAddress", + "HomeCity", + "HomeState", + "HomeZipCode", + "HomeCountry", + "WorkPOBox", + "WorkAddress2", + "WorkAddress", + "WorkCity", + "WorkState", + "WorkZipCode", + "WorkCountry", + "HomePhone", + "WorkPhone", + "FaxNumber", + "PagerNumber", + "CellularNumber", + "WebPage1", + "WebPage2", + "Custom1", + "Custom2", + "Custom3", + "Custom4", +]; + +/** Helper functions for typeMap. */ + +function singleTextProperty( + abPropName, + vPropName, + vPropParams = {}, + vPropType = "text" +) { + return { + /** + * Formats nsIAbCard properties into an array for use by ICAL.js. + * + * @param {Map} map - A map of address book properties to map. + * @yields {Array} - Values in a jCard array for use with ICAL.js. + */ + *fromAbCard(map) { + yield [vPropName, { ...vPropParams }, vPropType, map.get(abPropName)]; + }, + /** + * Parses a vCard value into properties usable by nsIAbCard. + * + * @param {string} value - vCard string to map to an address book card property. + * @yields {string[]} - Any number of key, value pairs to set on the nsIAbCard. + */ + *toAbCard(value) { + if (typeof value != "string") { + console.warn(`Unexpected value for ${vPropName}: ${value}`); + return; + } + yield [abPropName, value]; + }, + }; +} +function dateProperty(abCardPrefix, vPropName) { + return { + *fromAbCard(map) { + let year = map.get(`${abCardPrefix}Year`); + let month = map.get(`${abCardPrefix}Month`); + let day = map.get(`${abCardPrefix}Day`); + + if (!year && !month && !day) { + return; + } + + let dateValue = new ICAL.VCardTime({}, null, "date"); + // Set the properties directly instead of using the VCardTime + // constructor argument, which causes null values to become 0. + dateValue.year = year ? Number(year) : null; + dateValue.month = month ? Number(month) : null; + dateValue.day = day ? Number(day) : null; + + yield [vPropName, {}, "date", dateValue.toString()]; + }, + *toAbCard(value) { + try { + let dateValue = ICAL.VCardTime.fromDateAndOrTimeString(value); + yield [`${abCardPrefix}Year`, String(dateValue.year ?? "")]; + yield [`${abCardPrefix}Month`, String(dateValue.month ?? "")]; + yield [`${abCardPrefix}Day`, String(dateValue.day ?? "")]; + } catch (ex) { + console.error(ex); + } + }, + }; +} +function multiTextProperty(abPropNames, vPropName, vPropParams = {}) { + return { + *fromAbCard(map) { + if (abPropNames.every(name => !map.has(name))) { + return; + } + let vPropValues = abPropNames.map(name => map.get(name) || ""); + if (vPropValues.some(Boolean)) { + yield [vPropName, { ...vPropParams }, "text", vPropValues]; + } + }, + *toAbCard(value) { + if (Array.isArray(value)) { + for (let abPropName of abPropNames) { + let valuePart = value.shift(); + if (abPropName && valuePart) { + yield [ + abPropName, + Array.isArray(valuePart) ? valuePart.join(" ") : valuePart, + ]; + } + } + } else if (typeof value == "string") { + // Only one value was given. + yield [abPropNames[0], value]; + } else { + console.warn(`Unexpected value for ${vPropName}: ${value}`); + } + }, + }; +} + +/** + * Properties we support for conversion between nsIAbCard and vCard. + * + * Keys correspond to vCard property keys, with the type appended where more + * than one type is supported (e.g. work and home). + * + * Values are objects with toAbCard and fromAbCard functions which convert + * property values in each direction. See the docs on the object returned by + * singleTextProperty. + */ +var typeMap = { + fn: singleTextProperty("DisplayName", "fn"), + email: { + *fromAbCard(map) { + yield ["email", { pref: "1" }, "text", map.get("PrimaryEmail")]; + yield ["email", {}, "text", map.get("SecondEmail")]; + }, + toAbCard: singleTextProperty("PrimaryEmail", "email", { pref: "1" }) + .toAbCard, + }, + nickname: singleTextProperty("NickName", "nickname"), + note: singleTextProperty("Notes", "note"), + org: multiTextProperty(["Company", "Department"], "org"), + title: singleTextProperty("JobTitle", "title"), + bday: dateProperty("Birth", "bday"), + anniversary: dateProperty("Anniversary", "anniversary"), + n: multiTextProperty( + ["LastName", "FirstName", "AdditionalNames", "NamePrefix", "NameSuffix"], + "n" + ), + "adr.home": multiTextProperty( + [ + "HomePOBox", + "HomeAddress2", + "HomeAddress", + "HomeCity", + "HomeState", + "HomeZipCode", + "HomeCountry", + ], + "adr", + { type: "home" } + ), + "adr.work": multiTextProperty( + [ + "WorkPOBox", + "WorkAddress2", + "WorkAddress", + "WorkCity", + "WorkState", + "WorkZipCode", + "WorkCountry", + ], + "adr", + { type: "work" } + ), + "tel.home": singleTextProperty("HomePhone", "tel", { type: "home" }), + "tel.work": singleTextProperty("WorkPhone", "tel", { type: "work" }), + "tel.fax": singleTextProperty("FaxNumber", "tel", { type: "fax" }), + "tel.pager": singleTextProperty("PagerNumber", "tel", { type: "pager" }), + "tel.cell": singleTextProperty("CellularNumber", "tel", { type: "cell" }), + "url.work": singleTextProperty("WebPage1", "url", { type: "work" }, "url"), + "url.home": singleTextProperty("WebPage2", "url", { type: "home" }, "url"), + "x-custom1": singleTextProperty("Custom1", "x-custom1"), + "x-custom2": singleTextProperty("Custom2", "x-custom2"), + "x-custom3": singleTextProperty("Custom3", "x-custom3"), + "x-custom4": singleTextProperty("Custom4", "x-custom4"), +}; + +/** + * Any value that can be represented in a vCard. A value can be a boolean, + * number, string, or an array, depending on the data. A top-level array might + * contain primitives and/or second-level arrays of primitives. + * + * @see ICAL.design + * @see RFC6350 + * + * @typedef {boolean|number|string|vCardValue[]} vCardValue + */ + +/** + * Represents a single entry in a vCard ("contentline" in RFC6350 terms). + * The name, params, type and value are as returned by ICAL. + */ +class VCardPropertyEntry { + #name = null; + #params = null; + #type = null; + #value = null; + _original = null; + + /** + * @param {string} name + * @param {object} params + * @param {string} type + * @param {vCardValue} value + */ + constructor(name, params, type, value) { + this.#name = name; + this.#params = params; + this.#type = type; + if (params.encoding?.toUpperCase() == "QUOTED-PRINTABLE") { + if (Array.isArray(value)) { + value = value.map(VCardUtils._decodeQuotedPrintable); + } else { + value = VCardUtils._decodeQuotedPrintable(value); + } + delete params.encoding; + delete params.charset; + } + this.#value = value; + this._original = this; + } + + /** + * @type {string} + */ + get name() { + return this.#name; + } + + /** + * @type {object} + */ + get params() { + return this.#params; + } + + /** + * @type {string} + */ + get type() { + return this.#type; + } + set type(type) { + this.#type = type; + } + + /** + * @type {vCardValue} + */ + get value() { + return this.#value; + } + set value(value) { + this.#value = value; + } + + /** + * Clone this object. + * + * @returns {VCardPropertyEntry} + */ + clone() { + let cloneValue; + if (Array.isArray(this.#value)) { + cloneValue = this.#value.map(v => (Array.isArray(v) ? v.slice() : v)); + } else { + cloneValue = this.#value; + } + + let clone = new VCardPropertyEntry( + this.#name, + { ...this.#params }, + this.#type, + cloneValue + ); + clone._original = this; + return clone; + } + + /** + * @param {VCardPropertyEntry} other + */ + equals(other) { + if (other.constructor.name != "VCardPropertyEntry") { + return false; + } + return this._original == other._original; + } +} + +/** + * Represents an entire vCard as a collection of `VCardPropertyEntry` objects. + */ +class VCardProperties { + /** + * All of the vCard entries in this object. + * + * @type {VCardPropertyEntry[]} + */ + entries = []; + + /** + * @param {?string} version - The version of vCard to use. Valid values are + * "3.0" and "4.0". If unspecified, vCard 3.0 will be used. + */ + constructor(version) { + if (version) { + if (!["3.0", "4.0"].includes(version)) { + throw new Error(`Unsupported vCard version: ${version}`); + } + this.addEntry(new VCardPropertyEntry("version", {}, "text", version)); + } + } + + /** + * Parse a vCard into a VCardProperties object. + * + * @param {string} vCard + * @returns {VCardProperties} + */ + static fromVCard(vCard, { isGoogleCardDAV = false } = {}) { + vCard = VCardUtils.translateVCard21(vCard); + + let rv = new VCardProperties(); + let [, properties] = ICAL.parse(vCard); + for (let property of properties) { + let [name, params, type, value] = property; + if (property.length > 4) { + // The jCal format stores multiple values as the 4th...nth items. + // VCardPropertyEntry has only one place for a value, so store an + // array instead. This applies to CATEGORIES and NICKNAME types in + // vCard 4 and also NOTE in vCard 3. + value = property.slice(3); + } + if (isGoogleCardDAV) { + // Google escapes the characters \r : , ; and \ unnecessarily, in + // violation of RFC6350. Removing the escaping at this point means no + // other code requires a special case for it. + if (Array.isArray(value)) { + value = value.map(v => v.replace(/\\r/g, "\r").replace(/\\:/g, ":")); + } else { + value = value.replace(/\\r/g, "\r").replace(/\\:/g, ":"); + if (["phone-number", "uri"].includes(type)) { + value = value.replace(/\\([,;\\])/g, "$1"); + } + } + } + rv.addEntry(new VCardPropertyEntry(name, params, type, value)); + } + return rv; + } + + /** + * Parse a Map of Address Book properties into a VCardProperties object. + * + * @param {Map<string, string>} propertyMap + * @param {string} [version="4.0"] + * @returns {VCardProperties} + */ + static fromPropertyMap(propertyMap, version = "4.0") { + let rv = new VCardProperties(version); + + for (let vPropName of Object.keys(typeMap)) { + for (let vProp of typeMap[vPropName].fromAbCard(propertyMap, vPropName)) { + if (vProp[3] !== null && vProp[3] !== undefined && vProp[3] !== "") { + rv.addEntry(new VCardPropertyEntry(...vProp)); + } + } + } + + return rv; + } + + /** + * Used to determine the default value type when adding values. + * Either `ICAL.design.vcard` for (vCard 4.0) or `ICAL.design.vcard3` (3.0). + * + * @type {ICAL.design.designSet} + */ + designSet = ICAL.design.vcard3; + + /** + * Add an entry to this object. + * + * @param {VCardPropertyEntry} entry - The entry to add. + * @returns {boolean} - If the entry was added. + */ + addEntry(entry) { + if (entry.constructor.name != "VCardPropertyEntry") { + throw new Error("Not a VCardPropertyEntry"); + } + + if (this.entries.find(e => e.equals(entry))) { + return false; + } + + if (entry.name == "version") { + if (entry.value == "3.0") { + this.designSet = ICAL.design.vcard3; + } else if (entry.value == "4.0") { + this.designSet = ICAL.design.vcard; + } else { + throw new Error(`Unsupported vCard version: ${entry.value}`); + } + // Version must be the first entry, so clear out any existing values + // and add it to the start of the collection. + this.clearValues("version"); + this.entries.unshift(entry); + return true; + } + + this.entries.push(entry); + return true; + } + + /** + * Add an entry to this object by name and value. + * + * @param {string} name + * @param {string} value + * @returns {VCardPropertyEntry} + */ + addValue(name, value) { + for (let entry of this.getAllEntries(name)) { + if (entry.value == value) { + return entry; + } + } + + let newEntry = new VCardPropertyEntry( + name, + {}, + this.designSet.property[name].defaultType, + value + ); + this.entries.push(newEntry); + return newEntry; + } + + /** + * Remove an entry from this object. + * + * @param {VCardPropertyEntry} entry - The entry to remove. + * @returns {boolean} - If an entry was found and removed. + */ + removeEntry(entry) { + if (entry.constructor.name != "VCardPropertyEntry") { + throw new Error("Not a VCardPropertyEntry"); + } + + let index = this.entries.findIndex(e => e.equals(entry)); + if (index >= 0) { + this.entries.splice(index, 1); + return true; + } + return false; + } + + /** + * Remove entries from this object by name and value. All entries matching + * the name and value will be removed. + * + * @param {string} name + * @param {string} value + */ + removeValue(name, value) { + for (let entry of this.getAllEntries(name)) { + if (entry.value == value) { + this.removeEntry(entry); + } + } + } + + /** + * Remove entries from this object by name. All entries matching the name + * will be removed. + * + * @param {string} name + */ + clearValues(name) { + for (let entry of this.getAllEntries(name)) { + this.removeEntry(entry); + } + } + + /** + * Get the first value matching the given name, or null if no entry matches. + * + * @param {string} name + * @returns {?vCardValue} + */ + getFirstValue(name) { + let entry = this.entries.find(e => e.name == name); + if (entry) { + return entry.value; + } + return null; + } + + /** + * Get all values matching the given name. + * + * @param {string} name + * @returns {vCardValue[]} + */ + getAllValues(name) { + return this.getAllEntries(name).map(e => e.value); + } + + /** + * Get all values matching the given name, sorted in order of preference. + * Preference is determined by the `pref` parameter if it exists, then by + * the position in `entries`. + * + * @param {string} name + * @returns {vCardValue[]} + */ + getAllValuesSorted(name) { + return this.getAllEntriesSorted(name).map(e => e.value); + } + + /** + * Get the first entry matching the given name, or null if no entry matches. + * + * @param {string} name + * @returns {?VCardPropertyEntry} + */ + getFirstEntry(name) { + return this.entries.find(e => e.name == name) ?? null; + } + + /** + * Get all entries matching the given name. + * + * @param {string} name + * @returns {VCardPropertyEntry[]} + */ + getAllEntries(name) { + return this.entries.filter(e => e.name == name); + } + + /** + * Get all entries matching the given name, sorted in order of preference. + * Preference is determined by the `pref` parameter if it exists, then by + * the position in `entries`. + * + * @param {string} name + * @returns {VCardPropertyEntry[]} + */ + getAllEntriesSorted(name) { + let nextPref = 101; + let entries = this.getAllEntries(name).map(e => { + return { entry: e, pref: e.params.pref || nextPref++ }; + }); + entries.sort((a, b) => a.pref - b.pref); + return entries.map(e => e.entry); + } + + /** + * Get all entries matching the given group. + * + * @param {string} group + * @returns {VCardPropertyEntry[]} + */ + getGroupedEntries(group) { + return this.entries.filter(e => e.params.group == group); + } + + /** + * Clone this object. + * + * @returns {VCardProperties} + */ + clone() { + let copy = new VCardProperties(); + copy.entries = this.entries.map(e => e.clone()); + return copy; + } + + /** + * Get a Map of Address Book properties from this object. + * + * @returns {Map<string, string>} propertyMap + */ + toPropertyMap() { + let vPropMap = VCardUtils._parse(this.entries.map(e => e.clone())); + let propertyMap = new Map(); + + for (let [name, props] of vPropMap) { + // Store the value(s) on the abCard. + for (let [abPropName, abPropValue] of typeMap[name].toAbCard( + props[0].value + )) { + if (abPropValue) { + propertyMap.set(abPropName, abPropValue); + } + } + // Special case for email, which can also have a second preference. + if (name == "email" && props.length > 1) { + propertyMap.set("SecondEmail", props[1].value); + } + } + + return propertyMap; + } + + /** + * Serialize this object into a vCard. + * + * @returns {string} vCard + */ + toVCard() { + let jCal = this.entries.map(e => { + if (Array.isArray(e.value)) { + let design = this.designSet.property[e.name]; + if (design.multiValue == "," && !design.structuredValue) { + // The jCal format stores multiple values as the 4th...nth items, + // but VCardPropertyEntry stores them as an array. This applies to + // CATEGORIES and NICKNAME types in vCard 4 and also NOTE in vCard 3. + return [e.name, e.params, e.type, ...e.value]; + } + } + return [e.name, e.params, e.type, e.value]; + }); + return ICAL.stringify(["vcard", jCal]); + } +} diff --git a/comm/mailnews/addrbook/modules/components.conf b/comm/mailnews/addrbook/modules/components.conf new file mode 100644 index 0000000000..137150d06f --- /dev/null +++ b/comm/mailnews/addrbook/modules/components.conf @@ -0,0 +1,136 @@ +# 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/. + +Classes = [ + { + "cid": "{e96ee804-0bd3-472f-81a6-8a9d65277ad3}", + "contract_ids": ["@mozilla.org/addressbook/directory;1?type=jsaddrbook"], + "jsm": "resource:///modules/SQLiteDirectory.jsm", + "constructor": "SQLiteDirectory", + }, + { + "cid": "{1143991d-31cd-4ea6-9c97-c587d990d724}", + "contract_ids": ["@mozilla.org/addressbook/jsaddrbookcard;1"], + "jsm": "resource:///modules/AddrBookCard.jsm", + "constructor": "AddrBookCard", + }, + { + "cid": "{224d3ef9-d81c-4d94-8826-a79a5835af93}", + "contract_ids": ["@mozilla.org/abmanager;1"], + "jsm": "resource:///modules/AddrBookManager.jsm", + "constructor": "AddrBookManager", + "name": "AbManager", + "interfaces": ["nsIAbManager"], + }, + { + "cid": "{1fa9941a-07d5-4a6f-9673-15327fc2b9ab}", + "contract_ids": ["@mozilla.org/addressbook/directory;1?type=jscarddav"], + "jsm": "resource:///modules/CardDAVDirectory.jsm", + "constructor": "CardDAVDirectory", + }, + { + "cid": "{e2e0f615-bc5a-4441-a16b-a26e75949376}", + "contract_ids": ["@mozilla.org/addressbook/msgvcardservice;1"], + "jsm": "resource:///modules/VCardUtils.jsm", + "constructor": "VCardService", + }, + { + "cid": "{e9fb36ec-c980-4a77-9f68-0eb10491eda8}", + "contract_ids": ["@mozilla.org/mimecth;1?type=text/vcard"], + "jsm": "resource:///modules/VCardUtils.jsm", + "constructor": "VCardMimeConverter", + "categories": {"simple-mime-converters": "text/vcard"}, + }, + { + "cid": "{dafab386-bd4c-4238-bb48-228fbc98ba29}", + "contract_ids": ["@mozilla.org/mimecth;1?type=text/x-vcard"], + "jsm": "resource:///modules/VCardUtils.jsm", + "constructor": "VCardMimeConverter", + "categories": {"simple-mime-converters": "text/x-vcard"}, + }, + { + "cid": "{f87b71b5-2a0f-4b37-8e4f-3c899f6b8432}", + "contract_ids": ["@mozilla.org/network/ldap-connection;1"], + "jsm": "resource:///modules/LDAPConnection.jsm", + "constructor": "LDAPConnection", + }, + { + "cid": "{a6f94ca4-cd2d-4983-bcf2-fe936190955c}", + "contract_ids": ["@mozilla.org/network/ldap-operation;1"], + "jsm": "resource:///modules/LDAPOperation.jsm", + "constructor": "LDAPOperation", + }, + { + "cid": "{8683e821-f1b0-476d-ac15-07771c79bb11}", + "contract_ids": [ + "@mozilla.org/addressbook/directory;1?type=moz-abldapdirectory" + ], + "jsm": "resource:///modules/LDAPDirectory.jsm", + "constructor": "LDAPDirectory", + }, + { + "cid": "{5ad5d311-1a50-43db-a03c-63d45f443903}", + "contract_ids": ["@mozilla.org/addressbook/ldap-directory-query;1"], + "jsm": "resource:///modules/LDAPDirectoryQuery.jsm", + "constructor": "LDAPDirectoryQuery", + }, + { + "cid": "{dbe204e8-ae09-11eb-b4c8-a7e4b3e6e82e}", + "contract_ids": ["@mozilla.org/addressbook/ldap-replication-service;1"], + "jsm": "resource:///modules/LDAPReplicationService.jsm", + "constructor": "LDAPReplicationService", + }, + { + "cid": "{e8b59b32-f83f-4d5f-8eb5-e3c1e5de0d47}", + "contract_ids": ["@mozilla.org/network/ldap-service;1"], + "jsm": "resource:///modules/LDAPService.jsm", + "constructor": "LDAPService", + }, + { + "cid": "{50ca73fa-7deb-42b9-9eec-e219e31e6d4b}", + "contract_ids": ["@mozilla.org/network/ldap-url-parser;1"], + "jsm": "resource:///modules/LDAPURLParser.jsm", + "constructor": "LDAPURLParser", + }, + { + "cid": "{b3de9249-b0e5-4c12-8d91-c9a434fd80f5}", + "contract_ids": ["@mozilla.org/network/protocol;1?name=ldap"], + "jsm": "resource:///modules/LDAPProtocolHandler.jsm", + "constructor": "LDAPProtocolHandler", + "protocol_config": { + "scheme": "ldap", + "flags": [ + "URI_NORELATIVE", + "URI_DANGEROUS_TO_LOAD", + "ALLOWS_PROXY", + ], + "default_port": 389, + }, + }, + { + "cid": "{c85a5ef2-9c56-445f-b029-76889f2dd29b}", + "contract_ids": ["@mozilla.org/network/protocol;1?name=ldaps"], + "jsm": "resource:///modules/LDAPProtocolHandler.jsm", + "constructor": "LDAPSProtocolHandler", + "protocol_config": { + "scheme": "ldaps", + "flags": [ + "URI_NORELATIVE", + "URI_DANGEROUS_TO_LOAD", + "ALLOWS_PROXY", + ], + "default_port": 636, + }, + }, +] + +if buildconfig.substs["MOZ_PREF_EXTENSIONS"]: + Classes += [ + { + "cid": "{53d16809-1114-44e2-b585-41a2abb18f66}", + "contract_ids": ["@mozilla.org/ldapsyncquery;1"], + "jsm": "resource:///modules/LDAPSyncQuery.jsm", + "constructor": "LDAPSyncQuery", + }, + ] diff --git a/comm/mailnews/addrbook/modules/moz.build b/comm/mailnews/addrbook/modules/moz.build new file mode 100644 index 0000000000..8b86aa9555 --- /dev/null +++ b/comm/mailnews/addrbook/modules/moz.build @@ -0,0 +1,34 @@ +# 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/. + +EXTRA_JS_MODULES += [ + "AddrBookCard.jsm", + "AddrBookDirectory.jsm", + "AddrBookMailingList.jsm", + "AddrBookManager.jsm", + "AddrBookUtils.jsm", + "CardDAVDirectory.jsm", + "CardDAVUtils.jsm", + "LDAPClient.jsm", + "LDAPConnection.jsm", + "LDAPDirectory.jsm", + "LDAPDirectoryQuery.jsm", + "LDAPListenerBase.jsm", + "LDAPMessage.jsm", + "LDAPOperation.jsm", + "LDAPProtocolHandler.jsm", + "LDAPReplicationService.jsm", + "LDAPService.jsm", + "LDAPURLParser.jsm", + "QueryStringToExpression.jsm", + "SQLiteDirectory.jsm", + "VCardUtils.jsm", +] + +if CONFIG["MOZ_PREF_EXTENSIONS"]: + EXTRA_JS_MODULES += ["LDAPSyncQuery.jsm"] + +XPCOM_MANIFESTS += [ + "components.conf", +] |