summaryrefslogtreecommitdiffstats
path: root/content/includes
diff options
context:
space:
mode:
Diffstat (limited to 'content/includes')
-rw-r--r--content/includes/network.js431
-rw-r--r--content/includes/sync.js462
-rw-r--r--content/includes/tools.js198
3 files changed, 1091 insertions, 0 deletions
diff --git a/content/includes/network.js b/content/includes/network.js
new file mode 100644
index 0000000..3f94c30
--- /dev/null
+++ b/content/includes/network.js
@@ -0,0 +1,431 @@
+/*
+ * This file is part of DAV-4-TbSync.
+ *
+ * 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/.
+ */
+
+"use strict";
+
+var { HttpRequest } = ChromeUtils.import("chrome://tbsync/content/HttpRequest.jsm");
+var { OAuth2 } = ChromeUtils.import("resource:///modules/OAuth2.jsm");
+const { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm");
+
+var network = {
+
+ getAuthData: function(accountData) {
+ let connection = {
+ get host() {
+ return "TbSync#" + accountData.accountID;
+ },
+
+ get username() {
+ return accountData.getAccountProperty("user");
+ },
+
+ get password() {
+ // try new host first
+ let pw = TbSync.passwordManager.getLoginInfo(this.host, "TbSync/DAV", this.username);
+ if (pw) {
+ return pw;
+ }
+
+ // try old host as fallback
+ let oldHost = accountData.getAccountProperty("calDavHost") ? accountData.getAccountProperty("calDavHost") : accountData.getAccountProperty("cardDavHost");
+ if (oldHost.startsWith("http://")) oldHost = oldHost.substr(7);
+ if (oldHost.startsWith("https://")) oldHost = oldHost.substr(8);
+ pw = TbSync.passwordManager.getLoginInfo(oldHost, "TbSync/DAV", this.username);
+ if (pw) {
+ //migrate
+ this.updateLoginData(this.username, pw);
+ }
+ return pw;
+ },
+
+ updateLoginData: function(newUsername, newPassword) {
+ let oldUsername = this.username;
+ TbSync.passwordManager.updateLoginInfo(this.host, "TbSync/DAV", oldUsername, newUsername, newPassword);
+ // Also update the username of this account.
+ accountData.setAccountProperty("user", newUsername);
+ },
+
+ removeLoginData: function() {
+ TbSync.passwordManager.removeLoginInfos(this.host, "TbSync/DAV");
+ }
+ };
+ return connection;
+ },
+
+ ConnectionData: class {
+ constructor(data) {
+ this._password = "";
+ this._username = "";
+ this._https = "";
+ this._type = "";
+ this._fqdn = "";
+ this._timeout = dav.Base.getConnectionTimeout();
+
+ //for error logging
+ this._eventLogInfo = null;
+
+ //typof syncdata?
+ let folderData = null;
+ let accountData = null;
+
+ if (data instanceof TbSync.SyncData) {
+ folderData = data.currentFolderData;
+ accountData = data.accountData;
+ this._eventLogInfo = data.eventLogInfo;
+ } else if (data instanceof TbSync.FolderData) {
+ folderData = data;
+ accountData = data.accountData;
+ this._eventLogInfo = new TbSync.EventLogInfo(
+ accountData.getAccountProperty("provider"),
+ accountData.getAccountProperty("accountname"),
+ accountData.accountID,
+ folderData.getFolderProperty("foldername"));
+ } else if (data instanceof TbSync.AccountData) {
+ accountData = data;
+ this._eventLogInfo = new TbSync.EventLogInfo(
+ accountData.getAccountProperty("provider"),
+ accountData.getAccountProperty("accountname"),
+ accountData.accountID,
+ "");
+ }
+
+ if (accountData) {
+ let authData = dav.network.getAuthData(accountData);
+ this._password = authData.password;
+ this._username = authData.username;
+
+ this._accountname = accountData.getAccountProperty("accountname");
+ if (folderData) {
+ this._fqdn = folderData.getFolderProperty("fqdn");
+ this._https = folderData.getFolderProperty("https");
+ }
+ this.accountData = accountData;
+ }
+ }
+
+
+ set password(v) {this._password = v;}
+ set username(v) {this._username = v;}
+ set timeout(v) {this._timeout = v;}
+ set https(v) {this._https = v;}
+ set fqdn(v) {this._fqdn = v;}
+ set eventLogInfo(v) {this._eventLogInfo = v;}
+
+ get password() {return this._password;}
+ get username() {return this._username;}
+ get timeout() {return this._timeout;}
+ get https() {return this._https;}
+ get fqdn() {return this._fqdn;}
+ get eventLogInfo() {return this._eventLogInfo;}
+ },
+
+
+ checkForRFC6764Request: async function (path, eventLogInfo) {
+ function checkDefaultSecPort (sec) {
+ return sec ? "443" : "80";
+ }
+
+ if (!this.isRFC6764Request(path)) {
+ return path;
+ }
+
+ let parts = path.toLowerCase().split("6764://");
+ let type = parts[0].endsWith("caldav") ? "caldav" : "carddav";
+
+ // obey preselected security level for DNS lookup
+ // and only use insecure option if specified
+ let scheme = parts[0].startsWith("httpca") ? "http" : "https"; //httpcaldav or httpcarddav = httpca = http
+ let sec = (scheme == "https");
+
+ let hostPath = parts[1];
+ while (hostPath.endsWith("/")) { hostPath = hostPath.slice(0,-1); }
+ let host = hostPath.split("/")[0];
+
+ let result = {};
+
+ //only perform dns lookup, if the provided path does not contain any path information
+ if (host == hostPath) {
+ let request = "_" + type + (sec ? "s" : "") + "._tcp." + host;
+
+ // get host from SRV record
+ let rv = await DNS.srv(request);
+ if (rv && Array.isArray(rv) && rv.length>0 && rv[0].host) {
+ result.secure = sec;
+ result.host = rv[0].host + ((checkDefaultSecPort(sec) == rv[0].port) ? "" : ":" + rv[0].port);
+ TbSync.eventlog.add("info", eventLogInfo, "RFC6764 DNS request succeeded", "SRV record @ " + request + "\n" + JSON.stringify(rv[0]));
+
+ // Now try to get path from TXT
+ rv = await DNS.txt(request);
+ if (rv && Array.isArray(rv) && rv.length>0 && rv[0].data && rv[0].data.toLowerCase().startsWith("path=")) {
+ result.path = rv[0].data.substring(5);
+ TbSync.eventlog.add("info", eventLogInfo, "RFC6764 DNS request succeeded", "TXT record @ " + request + "\n" + JSON.stringify(rv[0]));
+ } else {
+ result.path = "/.well-known/" + type;
+ }
+
+ result.url = "http" + (result.secure ? "s" : "") + "://" + result.host + result.path;
+ return result.url;
+ } else {
+ TbSync.eventlog.add("warning", eventLogInfo, "RFC6764 DNS request failed", "SRV record @ " + request);
+ }
+ }
+
+ // use the provided hostPath and build standard well-known url
+ return scheme + "://" + hostPath + "/.well-known/" + type;
+ },
+
+ startsWithScheme: function (url) {
+ return (url.toLowerCase().startsWith("http://") || url.toLowerCase().startsWith("https://") || this.isRFC6764Request(url));
+ },
+
+ isRFC6764Request: function (url) {
+ let parts = url.split("6764://");
+ return (parts.length == 2 && parts[0].endsWith("dav"));
+ },
+
+ sendRequest: async function (requestData, path, method, connectionData, headers = {}, options = {}) {
+ let url = await this.checkForRFC6764Request(path, connectionData.eventLogInfo);
+ let enforcedPermanentlyRedirectedUrl = (url != path) ? url : null;
+
+ // path could be absolute or relative, we may need to rebuild the full url.
+ if (url.startsWith("http://") || url.startsWith("https://")) {
+ // extract segments from url
+ let uri = Services.io.newURI(url);
+ connectionData.https = (uri.scheme == "https");
+ connectionData.fqdn = uri.hostPort;
+ } else {
+ url = "http" + (connectionData.https ? "s" : "") + "://" + connectionData.fqdn + url;
+ }
+
+ let currentSyncState = connectionData.accountData ? connectionData.accountData.syncData.getSyncState().state : "";
+ let accountID = connectionData.accountData ? connectionData.accountData.accountID : "";
+
+ // Loop: Prompt user for password and retry
+ const MAX_RETRIES = options.hasOwnProperty("passwordRetries") ? options.passwordRetries+1 : 5;
+ for (let i=1; i <= MAX_RETRIES; i++) {
+ TbSync.dump("URL Request #" + i, url);
+
+ connectionData.url = url;
+
+ // Restore original syncstate before open the connection
+ if (connectionData.accountData && currentSyncState != connectionData.accountData.syncData.getSyncState().state) {
+ connectionData.accountData.syncData.setSyncState(currentSyncState);
+ }
+
+ let r = await dav.network.promisifiedHttpRequest(requestData, method, connectionData, headers, options);
+ if (r && enforcedPermanentlyRedirectedUrl && !r.permanentlyRedirectedUrl) {
+ r.permanentlyRedirectedUrl = enforcedPermanentlyRedirectedUrl;
+ }
+
+ if (r && r.passwordPrompt && r.passwordPrompt === true) {
+ if (i == MAX_RETRIES) {
+ // If this is the final retry, abort with error.
+ throw r.passwordError;
+ } else {
+ let credentials = null;
+ let retry = false;
+
+ // Prompt, if connection belongs to an account (and not from the create wizard)
+ if (connectionData.accountData) {
+ let promptData = {
+ windowID: "auth:" + connectionData.accountData.accountID,
+ accountname: connectionData.accountData.getAccountProperty("accountname"),
+ usernameLocked: connectionData.accountData.isConnected(),
+ username: connectionData.username,
+ }
+ connectionData.accountData.syncData.setSyncState("passwordprompt");
+
+ credentials = await TbSync.passwordManager.asyncPasswordPrompt(promptData, dav.openWindows);
+ if (credentials) {
+ // update login data
+ dav.network.getAuthData(connectionData.accountData).updateLoginData(credentials.username, credentials.password);
+ // update connection data
+ connectionData.username = credentials.username;
+ connectionData.password = credentials.password;
+ retry = true;
+ }
+ }
+
+ if (!retry) {
+ throw r.passwordError;
+ }
+
+ }
+ } else {
+ return r;
+ }
+ }
+ },
+
+ // Promisified implementation of TbSync's HttpRequest (with XHR interface)
+ promisifiedHttpRequest: function (requestData, method, connectionData, headers, options) {
+ let responseData = "";
+
+ //do not log HEADERS, as it could contain an Authorization header
+ //TbSync.dump("HEADERS", JSON.stringify(headers));
+ if (TbSync.prefs.getIntPref("log.userdatalevel") > 1) TbSync.dump("REQUEST", method + " : " + requestData);
+
+ if (!options.hasOwnProperty("softfail")) {
+ options.softfail = [];
+ }
+
+ if (!options.hasOwnProperty("responseType")) {
+ options.responseType = "xml";
+ }
+
+ return new Promise(function(resolve, reject) {
+ let req = new HttpRequest();
+
+ req.timeout = connectionData.timeout;
+ req.mozBackgroundRequest = true;
+
+ req.open(method, connectionData.url, true, connectionData.username, connectionData.password);
+
+ if (options.hasOwnProperty("containerRealm")) req.setContainerRealm(options.containerRealm);
+ if (options.hasOwnProperty("containerReset") && options.containerReset == true) req.clearContainerCache();
+
+ if (headers) {
+ for (let header in headers) {
+ req.setRequestHeader(header, headers[header]);
+ }
+ }
+
+ if (options.responseType == "base64") {
+ req.responseAsBase64 = true;
+ }
+
+ req.setRequestHeader("User-Agent", dav.sync.prefSettings.getCharPref("clientID.useragent"));
+
+ req.realmCallback = function(username, realm, host) {
+ // Store realm, needed later to setup lightning passwords.
+ TbSync.dump("Found CalDAV authRealm for <"+host+">", realm);
+ connectionData.realm = realm;
+ };
+
+ req.onerror = function () {
+ let error = TbSync.network.createTCPErrorFromFailedXHR(req);
+ if (!error) {
+ return reject(dav.sync.finish("error", "networkerror", "URL:\n" + connectionData.url + " ("+method+")")); //reject/resolve do not terminate control flow
+ } else {
+ return reject(dav.sync.finish("error", error, "URL:\n" + connectionData.url + " ("+method+")"));
+ }
+ };
+
+ req.ontimeout = req.onerror;
+
+ req.onredirect = function(flags, uri) {
+ console.log("Redirect ("+ flags.toString(2) +"): " + uri.spec);
+ // Update connection settings from current URL
+ let newHttps = (uri.scheme == "https");
+ if (connectionData.https != newHttps) {
+ TbSync.dump("Updating HTTPS", connectionData.https + " -> " + newHttps);
+ connectionData.https = newHttps;
+ }
+ if (connectionData.fqdn !=uri.hostPort) {
+ TbSync.dump("Updating FQDN", connectionData.fqdn + " -> " + uri.hostPort);
+ connectionData.fqdn = uri.hostPort;
+ }
+ };
+
+ req.onload = function() {
+ if (TbSync.prefs.getIntPref("log.userdatalevel") > 1) TbSync.dump("RESPONSE", req.status + " ("+req.statusText+")" + " : " + req.responseText);
+ responseData = req.responseText.split("><").join(">\n<");
+
+ let commLog = "URL:\n" + connectionData.url + " ("+method+")" + "\n\nRequest:\n" + requestData + "\n\nResponse:\n" + responseData;
+ let aResult = req.responseText;
+ let responseStatus = req.status;
+
+ switch(responseStatus) {
+ case 401: //AuthError
+ {
+ let response = {};
+ response.passwordPrompt = true;
+ response.passwordError = dav.sync.finish("error", responseStatus, commLog);
+ return resolve(response);
+ }
+ break;
+
+ case 207: //preprocess multiresponse
+ {
+ let xml = dav.tools.convertToXML(aResult);
+ if (xml === null) return reject(dav.sync.finish("warning", "malformed-xml", commLog));
+
+ let response = {};
+ response.davOptions = req.getResponseHeader("dav");
+ response.responseURL = req.responseURL;
+ response.permanentlyRedirectedUrl = req.permanentlyRedirectedUrl;
+ response.commLog = commLog;
+ response.node = xml.documentElement;
+
+ let multi = xml.documentElement.getElementsByTagNameNS(dav.sync.ns.d, "response");
+ response.multi = [];
+ for (let i=0; i < multi.length; i++) {
+ let hrefNode = dav.tools.evaluateNode(multi[i], [["d","href"]]);
+ let responseStatusNode = dav.tools.evaluateNode(multi[i], [["d", "status"]]);
+ let propstats = multi[i].getElementsByTagNameNS(dav.sync.ns.d, "propstat");
+ if (propstats.length > 0) {
+ //response contains propstats, push each as single entry
+ for (let p=0; p < propstats.length; p++) {
+ let statusNode = dav.tools.evaluateNode(propstats[p], [["d", "status"]]);
+
+ let resp = {};
+ resp.node = propstats[p];
+ resp.status = statusNode === null ? null : statusNode.textContent.split(" ")[1];
+ resp.responsestatus = responseStatusNode === null ? null : responseStatusNode.textContent.split(" ")[1];
+ resp.href = hrefNode === null ? null : hrefNode.textContent;
+ response.multi.push(resp);
+ }
+ } else {
+ //response does not contain any propstats, push raw response
+ let resp = {};
+ resp.node = multi[i];
+ resp.status = responseStatusNode === null ? null : responseStatusNode.textContent.split(" ")[1];
+ resp.responsestatus = responseStatusNode === null ? null : responseStatusNode.textContent.split(" ")[1];
+ resp.href = hrefNode === null ? null : hrefNode.textContent;
+ response.multi.push(resp);
+ }
+ }
+
+ return resolve(response);
+ }
+
+
+ case 200: //returned by DELETE by radicale - watch this !!!
+ return resolve(aResult);
+
+ case 204: //is returned by DELETE - no data
+ case 201: //is returned by CREATE - no data
+ return resolve(null);
+ break;
+
+ default:
+ if (options.softfail.includes(responseStatus)) {
+ let noresponse = {};
+ noresponse.softerror = responseStatus;
+ let xml = dav.tools.convertToXML(aResult);
+ if (xml !== null) {
+ let exceptionNode = dav.tools.evaluateNode(xml.documentElement, [["s","exception"]]);
+ if (exceptionNode !== null) {
+ noresponse.exception = exceptionNode.textContent;
+ }
+ }
+ //manually log this non-fatal error
+ TbSync.eventlog.add("info", connectionData.eventLogInfo, "softerror::"+responseStatus, commLog);
+ return resolve(noresponse);
+ } else {
+ return reject(dav.sync.finish("warning", responseStatus, commLog));
+ }
+ break;
+
+ }
+ };
+
+ req.send(requestData);
+ });
+ }
+}
diff --git a/content/includes/sync.js b/content/includes/sync.js
new file mode 100644
index 0000000..5a73bb3
--- /dev/null
+++ b/content/includes/sync.js
@@ -0,0 +1,462 @@
+/*
+/*
+ * This file is part of DAV-4-TbSync.
+ *
+ * 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/.
+ */
+
+"use strict";
+
+const { CardDAVDirectory } = ChromeUtils.import(
+ "resource:///modules/CardDAVDirectory.jsm"
+);
+
+var sync = {
+
+ finish: function (aStatus = "", msg = "", details = "") {
+ let status = TbSync.StatusData.SUCCESS
+ switch (aStatus) {
+
+ case "":
+ case "ok":
+ status = TbSync.StatusData.SUCCESS;
+ break;
+
+ case "info":
+ status = TbSync.StatusData.INFO;
+ break;
+
+ case "resyncAccount":
+ status = TbSync.StatusData.ACCOUNT_RERUN;
+ break;
+
+ case "resyncFolder":
+ status = TbSync.StatusData.FOLDER_RERUN;
+ break;
+
+ case "warning":
+ status = TbSync.StatusData.WARNING;
+ break;
+
+ case "error":
+ status = TbSync.StatusData.ERROR;
+ break;
+
+ default:
+ console.log("TbSync/DAV: Unknown status <"+aStatus+">");
+ status = TbSync.StatusData.ERROR;
+ break;
+ }
+
+ let e = new Error();
+ e.name = "dav4tbsync";
+ e.message = status.toUpperCase() + ": " + msg.toString() + " (" + details.toString() + ")";
+ e.statusData = new TbSync.StatusData(status, msg.toString(), details.toString());
+ return e;
+ },
+
+ prefSettings: Services.prefs.getBranch("extensions.dav4tbsync."),
+
+ ns: {
+ d: "DAV:",
+ cal: "urn:ietf:params:xml:ns:caldav" ,
+ card: "urn:ietf:params:xml:ns:carddav" ,
+ cs: "http://calendarserver.org/ns/",
+ s: "http://sabredav.org/ns",
+ apple: "http://apple.com/ns/ical/"
+ },
+
+ serviceproviders: {
+ "fruux" : {revision: 1, icon: "fruux", caldav: "https://dav.fruux.com", carddav: "https://dav.fruux.com"},
+ "mbo" : {revision: 1, icon: "mbo", caldav: "caldav6764://mailbox.org", carddav: "carddav6764://mailbox.org"},
+ "icloud" : {revision: 1, icon: "icloud", caldav: "https://caldav.icloud.com", carddav: "https://contacts.icloud.com"},
+ "gmx.net" : {revision: 1, icon: "gmx", caldav: "caldav6764://gmx.net", carddav: "carddav6764://gmx.net"},
+ "gmx.com" : {revision: 1, icon: "gmx", caldav: "caldav6764://gmx.com", carddav: "carddav6764://gmx.com"},
+ "posteo" : {revision: 1, icon: "posteo", caldav: "https://posteo.de:8443", carddav: "posteo.de:8843"},
+ "web.de" : {revision: 1, icon: "web", caldav: "caldav6764://web.de", carddav: "carddav6764://web.de"},
+ "yahoo" : {revision: 1, icon: "yahoo", caldav: "caldav6764://yahoo.com", carddav: "carddav6764://yahoo.com"},
+ },
+
+ resetFolderSyncInfo : function (folderData) {
+ folderData.resetFolderProperty("ctag");
+ folderData.resetFolderProperty("token");
+ folderData.setFolderProperty("createdWithProviderVersion", folderData.accountData.providerData.getVersion());
+ },
+
+ folderList: async function (syncData) {
+ //Method description: http://sabre.io/dav/building-a-caldav-client/
+ //get all folders currently known
+ let folderTypes = ["caldav", "carddav", "ics"];
+ let unhandledFolders = {};
+ for (let type of folderTypes) {
+ unhandledFolders[type] = [];
+ }
+
+
+ let folders = syncData.accountData.getAllFolders();
+ for (let folder of folders) {
+ //just in case
+ if (!unhandledFolders.hasOwnProperty(folder.getFolderProperty("type"))) {
+ unhandledFolders[folder.getFolderProperty("type")] = [];
+ }
+ unhandledFolders[folder.getFolderProperty("type")].push(folder);
+ }
+
+ // refresh urls of service provider, if they have been updated
+ let serviceprovider = syncData.accountData.getAccountProperty("serviceprovider");
+ let serviceproviderRevision = syncData.accountData.getAccountProperty("serviceproviderRevision");
+ if (dav.sync.serviceproviders.hasOwnProperty(serviceprovider) && serviceproviderRevision != dav.sync.serviceproviders[serviceprovider].revision) {
+ TbSync.eventlog.add("info", syncData.eventLogInfo, "updatingServiceProvider", serviceprovider);
+ syncData.accountData.setAccountProperty("serviceproviderRevision", dav.sync.serviceproviders[serviceprovider].revision);
+ syncData.accountData.resetAccountProperty("calDavPrincipal");
+ syncData.accountData.resetAccountProperty("cardDavPrincipal");
+ syncData.accountData.setAccountProperty("calDavHost", dav.sync.serviceproviders[serviceprovider].caldav);
+ syncData.accountData.setAccountProperty("cardDavHost", dav.sync.serviceproviders[serviceprovider].carddav);
+ }
+
+ let davjobs = {
+ cal : {server: syncData.accountData.getAccountProperty("calDavHost")},
+ card : {server: syncData.accountData.getAccountProperty("cardDavHost")},
+ };
+
+ for (let job in davjobs) {
+ if (!davjobs[job].server) continue;
+
+ // SOGo needs some special handling for shared addressbooks. We detect
+ // it by having SOGo/dav in the url.
+ let isSogo = davjobs[job].server.includes("/SOGo/dav");
+
+ // sync states are only printed while the account state is "syncing"
+ // to inform user about sync process (it is not stored in DB, just in
+ // syncData)
+ // example state "getfolders" to get folder information from server
+ // if you send a request to a server and thus have to wait for answer,
+ // use a "send." syncstate, which will give visual feedback to the user,
+ // that we are waiting for an answer with timeout countdown
+
+ let home = [];
+ let own = [];
+
+ // migration code for http setting, we might keep it as a fallback, if user removed the http:// scheme from the url in the settings
+ if (!dav.network.startsWithScheme(davjobs[job].server)) {
+ davjobs[job].server = "http" + (syncData.accountData.getAccountProperty("https") ? "s" : "") + "://" + davjobs[job].server;
+ syncData.accountData.setAccountProperty(job + "DavHost", davjobs[job].server);
+ }
+
+ //add connection to syncData
+ syncData.connectionData = new dav.network.ConnectionData(syncData);
+
+ //only do that, if a new calendar has been enabled
+ TbSync.network.resetContainerForUser(syncData.connectionData.username);
+
+ syncData.setSyncState("send.getfolders");
+ let principal = syncData.accountData.getAccountProperty(job + "DavPrincipal"); // defaults to null
+ if (principal === null) {
+
+ let response = await dav.network.sendRequest("<d:propfind "+dav.tools.xmlns(["d"])+"><d:prop><d:current-user-principal /></d:prop></d:propfind>", davjobs[job].server , "PROPFIND", syncData.connectionData, {"Depth": "0", "Prefer": "return=minimal"});
+ syncData.setSyncState("eval.folders");
+
+ // keep track of permanent redirects for the server URL
+ if (response && response.permanentlyRedirectedUrl) {
+ syncData.accountData.setAccountProperty(job + "DavHost", response.permanentlyRedirectedUrl)
+ }
+
+ // store dav options send by server
+ if (response && response.davOptions) {
+ syncData.accountData.setAccountProperty(job + "DavOptions", response.davOptions.split(",").map(e => e.trim()));
+ }
+
+ // allow 404 because iCloud sends it on valid answer (yeah!)
+ if (response && response.multi) {
+ principal = dav.tools.getNodeTextContentFromMultiResponse(response, [["d","prop"], ["d","current-user-principal"], ["d","href"]], null, ["200","404"]);
+ }
+ }
+
+ //principal now contains something like "/remote.php/carddav/principals/john.bieling/"
+ //principal can also be an absolute url
+ // -> get home/root of storage
+ if (principal !== null) {
+ syncData.setSyncState("send.getfolders");
+
+ let options = syncData.accountData.getAccountProperty(job + "DavOptions");
+
+ let homeset = (job == "cal")
+ ? "calendar-home-set"
+ : "addressbook-home-set";
+
+ let request = "<d:propfind "+dav.tools.xmlns(["d", job, "cs"])+"><d:prop><"+job+":" + homeset + " />"
+ + (job == "cal" && options.includes("calendar-proxy") ? "<cs:calendar-proxy-write-for /><cs:calendar-proxy-read-for />" : "")
+ + "<d:group-membership />"
+ + "</d:prop></d:propfind>";
+
+ let response = await dav.network.sendRequest(request, principal, "PROPFIND", syncData.connectionData, {"Depth": "0", "Prefer": "return=minimal"});
+ syncData.setSyncState("eval.folders");
+
+ // keep track of permanent redirects for the principal URL
+ if (response && response.permanentlyRedirectedUrl) {
+ principal = response.permanentlyRedirectedUrl;
+ }
+
+ own = dav.tools.getNodesTextContentFromMultiResponse(response, [["d","prop"], [job, homeset ], ["d","href"]], principal);
+ home = own.concat(dav.tools.getNodesTextContentFromMultiResponse(response, [["d","prop"], ["cs", "calendar-proxy-read-for" ], ["d","href"]], principal));
+ home = home.concat(dav.tools.getNodesTextContentFromMultiResponse(response, [["d","prop"], ["cs", "calendar-proxy-write-for" ], ["d","href"]], principal));
+
+ //Any groups we need to find? Only diving one level at the moment,
+ let g = dav.tools.getNodesTextContentFromMultiResponse(response, [["d","prop"], ["d", "group-membership" ], ["d","href"]], principal);
+ for (let gc=0; gc < g.length; gc++) {
+ //SOGo reports a 403 if I request the provided resource, also since we do not dive, remove the request for group-membership
+ response = await dav.network.sendRequest(request.replace("<d:group-membership />",""), g[gc], "PROPFIND", syncData.connectionData, {"Depth": "0", "Prefer": "return=minimal"}, {softfail: [403, 404]});
+ if (response && response.softerror) {
+ continue;
+ }
+ home = home.concat(dav.tools.getNodesTextContentFromMultiResponse(response, [["d","prop"], [job, homeset ], ["d","href"]], g[gc]));
+ }
+
+ //calendar-proxy and group-membership could have returned the same values, make the homeset unique
+ home = home.filter((v,i,a) => a.indexOf(v) == i);
+ } else {
+ // do not throw here, but log the error and skip this server
+ TbSync.eventlog.add("error", syncData.eventLogInfo, job+"davservernotfound", davjobs[job].server);
+ }
+
+ //home now contains something like /remote.php/caldav/calendars/john.bieling/
+ // -> get all resources
+ if (home.length > 0) {
+ // the used principal returned valid resources, store/update it
+ // as the principal is being used as a starting point, it must be stored as absolute url
+ syncData.accountData.setAccountProperty(job + "DavPrincipal", dav.network.startsWithScheme(principal)
+ ? principal
+ : "http" + (syncData.connectionData.https ? "s" : "") + "://" + syncData.connectionData.fqdn + principal);
+
+ for (let h=0; h < home.length; h++) {
+ syncData.setSyncState("send.getfolders");
+ let request = (job == "cal")
+ ? "<d:propfind "+dav.tools.xmlns(["d","apple","cs","cal"])+"><d:prop><d:current-user-privilege-set/><d:resourcetype /><d:displayname /><apple:calendar-color/><cs:source/><cal:supported-calendar-component-set/></d:prop></d:propfind>"
+ : "<d:propfind "+dav.tools.xmlns(["d"])+"><d:prop><d:current-user-privilege-set/><d:resourcetype /><d:displayname /></d:prop></d:propfind>";
+
+ //some servers report to have calendar-proxy-read but return a 404 when that gets actually queried
+ let response = await dav.network.sendRequest(request, home[h], "PROPFIND", syncData.connectionData, {"Depth": "1", "Prefer": "return=minimal"}, {softfail: [403, 404]});
+ if (response && response.softerror) {
+ continue;
+ }
+
+ for (let r=0; r < response.multi.length; r++) {
+ if (response.multi[r].status != "200") continue;
+
+ let resourcetype = null;
+ //is this a result with a valid recourcetype? (the node must be present)
+ switch (job) {
+ case "card":
+ if (dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["d","resourcetype"], ["card", "addressbook"]]) !== null) resourcetype = "carddav";
+ break;
+
+ case "cal":
+ if (dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["d","resourcetype"], ["cal", "calendar"]]) !== null) resourcetype = "caldav";
+ else if (dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["d","resourcetype"], ["cs", "subscribed"]]) !== null) resourcetype = "ics";
+ break;
+ }
+ if (resourcetype === null) continue;
+
+ //get ACL (grant read rights per default, if it is SOGo, as they do not send that permission)
+ let acl = isSogo ? 0x1 : 0;
+
+ let privilegNode = dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["d","current-user-privilege-set"]]);
+ if (privilegNode) {
+ if (privilegNode.getElementsByTagNameNS(dav.sync.ns.d, "all").length > 0) {
+ acl = 0xF; //read=1, mod=2, create=4, delete=8
+ } else {
+ // check for individual write permissions
+ if (privilegNode.getElementsByTagNameNS(dav.sync.ns.d, "write").length > 0) {
+ acl = 0xF;
+ } else {
+ if (privilegNode.getElementsByTagNameNS(dav.sync.ns.d, "write-content").length > 0) acl |= 0x2;
+ if (privilegNode.getElementsByTagNameNS(dav.sync.ns.d, "bind").length > 0) acl |= 0x4;
+ if (privilegNode.getElementsByTagNameNS(dav.sync.ns.d, "unbind").length > 0) acl |= 0x8;
+ }
+
+ // check for read permission (implying read if any write is given)
+ if (privilegNode.getElementsByTagNameNS(dav.sync.ns.d, "read").length > 0 || acl != 0) acl |= 0x1;
+ }
+ }
+
+ //ignore this resource, if no read access
+ if ((acl & 0x1) == 0) continue;
+
+ let href = response.multi[r].href;
+ if (resourcetype == "ics") href = dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["cs","source"], ["d","href"]]).textContent;
+
+ let name_node = dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["d","displayname"]]);
+ let name = TbSync.getString("defaultname." + ((job == "cal") ? "calendar" : "contacts") , "dav");
+ if (name_node != null) {
+ name = name_node.textContent;
+ }
+ let color = dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["apple","calendar-color"]]);
+ let supportedCalComponent = dav.tools.evaluateNode(response.multi[r].node, [["d","prop"], ["cal","supported-calendar-component-set"]]);
+ if (supportedCalComponent) {
+ supportedCalComponent = Array.from(supportedCalComponent.children, e => e.getAttribute("name"));
+ } else {
+ supportedCalComponent = [];
+ }
+ if (job == "cal" && supportedCalComponent.length > 0 && !supportedCalComponent.includes("VTODO") && !supportedCalComponent.includes("VEVENT")) {
+ // This does not seem to be a valid resource.
+ continue;
+ }
+
+ //remove found folder from list of unhandled folders
+ unhandledFolders[resourcetype] = unhandledFolders[resourcetype].filter(item => item.getFolderProperty("href") !== href);
+
+
+ // interaction with TbSync
+ // do we have a folder for that href?
+ let folderData = syncData.accountData.getFolder("href", href);
+ if (!folderData) {
+ // create a new folder entry
+ folderData = syncData.accountData.createNewFolder();
+ // this MUST be set to either "addressbook" or "calendar" to use the standard target support, or any other value, which
+ // requires a corresponding targets implementation by this provider
+ folderData.setFolderProperty("targetType", (job == "card") ? "addressbook" : "calendar");
+
+ folderData.setFolderProperty("href", href);
+ folderData.setFolderProperty("foldername", name);
+ folderData.setFolderProperty("type", resourcetype);
+ folderData.setFolderProperty("supportedCalComponent", supportedCalComponent);
+ folderData.setFolderProperty("shared", !own.includes(home[h]));
+ folderData.setFolderProperty("acl", acl.toString());
+ folderData.setFolderProperty("downloadonly", (acl == 0x1)); //if any write access is granted, setup as writeable
+
+ //we assume the folder has the same fqdn as the homeset, otherwise href must contain the full URL and the fqdn is ignored
+ folderData.setFolderProperty("fqdn", syncData.connectionData.fqdn);
+ folderData.setFolderProperty("https", syncData.connectionData.https);
+
+ //do we have a cached folder?
+ let cachedFolderData = syncData.accountData.getFolderFromCache("href", href);
+ if (cachedFolderData) {
+ // copy fields from cache which we want to re-use
+ folderData.setFolderProperty("targetColor", cachedFolderData.getFolderProperty("targetColor"));
+ folderData.setFolderProperty("targetName", cachedFolderData.getFolderProperty("targetName"));
+ //if we have only READ access, do not restore cached value for downloadonly
+ if (acl > 0x1) folderData.setFolderProperty("downloadonly", cachedFolderData.getFolderProperty("downloadonly"));
+ }
+ } else {
+ //Update name & color
+ folderData.setFolderProperty("foldername", name);
+ folderData.setFolderProperty("fqdn", syncData.connectionData.fqdn);
+ folderData.setFolderProperty("https", syncData.connectionData.https);
+ folderData.setFolderProperty("acl", acl);
+ //if the acl changed from RW to RO we need to update the downloadonly setting
+ if (acl == 0x1) {
+ folderData.setFolderProperty("downloadonly", true);
+ }
+ }
+
+ // Update color from server.
+ if (color && job == "cal") {
+ color = color.textContent.substring(0,7);
+ folderData.setFolderProperty("targetColor", color);
+
+ // Do we have to update the calendar?
+ if (folderData.targetData && folderData.targetData.hasTarget()) {
+ try {
+ let targetCal = await folderData.targetData.getTarget();
+ targetCal.calendar.setProperty("color", color);
+ } catch (e) {
+ Components.utils.reportError(e)
+ }
+ }
+ }
+ }
+ }
+ } else {
+ //home was not found - connection error? - do not delete unhandled folders
+ switch (job) {
+ case "card":
+ unhandledFolders.carddav = [];
+ break;
+
+ case "cal":
+ unhandledFolders.caldav = [];
+ unhandledFolders.ics = [];
+ break;
+ }
+ //reset stored principal
+ syncData.accountData.resetAccountProperty(job + "DavPrincipal");
+ }
+ }
+
+ // Remove unhandled old folders, (because they no longer exist on the server).
+ // Do not delete the targets, but keep them as stale/unconnected elements.
+ for (let type of folderTypes) {
+ for (let folder of unhandledFolders[type]) {
+ folder.remove("[deleted on server]");
+ }
+ }
+ },
+
+
+
+
+
+
+ folder: async function (syncData) {
+ // add connection data to syncData
+ syncData.connectionData = new dav.network.ConnectionData(syncData);
+
+ // add target to syncData
+ let hadTarget;
+ try {
+ // accessing the target for the first time will check if it is avail and if not will create it (if possible)
+ hadTarget = syncData.currentFolderData.targetData.hasTarget();
+ syncData.target = await syncData.currentFolderData.targetData.getTarget();
+ } catch (e) {
+ Components.utils.reportError(e);
+ throw dav.sync.finish("warning", e.message);
+ }
+
+ switch (syncData.currentFolderData.getFolderProperty("type")) {
+ case "carddav":
+ {
+ // update downloadonly - we do not use AbDirectory (syncData.target) but the underlying thunderbird addressbook obj
+ if (syncData.currentFolderData.getFolderProperty("downloadonly")) syncData.target.directory.setBoolValue("readOnly", true);
+
+ try {
+ let davDirectory = CardDAVDirectory.forFile(syncData.target.directory.fileName);
+ if (!hadTarget) {
+ davDirectory.fetchAllFromServer();
+ } else {
+ davDirectory.syncWithServer();
+ }
+ } catch (ex) {
+ throw dav.sync.finish("error", "non-carddav-addrbook");
+ }
+
+ throw dav.sync.finish("ok", "managed-by-thunderbird");
+ }
+ break;
+
+ case "caldav":
+ case "ics":
+ {
+ // update downloadonly - we do not use TbCalendar (syncData.target) but the underlying lightning calendar obj
+ if (syncData.currentFolderData.getFolderProperty("downloadonly")) syncData.target.calendar.setProperty("readOnly", true);
+
+ // update username of calendar
+ syncData.target.calendar.setProperty("username", syncData.connectionData.username);
+
+ //init sync via lightning
+ if (hadTarget) syncData.target.calendar.refresh();
+
+ throw dav.sync.finish("ok", "managed-by-thunderbird");
+ }
+ break;
+
+ default:
+ {
+ throw dav.sync.finish("warning", "notsupported");
+ }
+ break;
+ }
+ },
+
+}
diff --git a/content/includes/tools.js b/content/includes/tools.js
new file mode 100644
index 0000000..df51d79
--- /dev/null
+++ b/content/includes/tools.js
@@ -0,0 +1,198 @@
+/*
+ * This file is part of DAV-4-TbSync.
+ *
+ * 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/.
+ */
+
+"use strict";
+
+var tools = {
+
+ //* * * * * * * * * * * * *
+ //* UTILS
+ //* * * * * * * * * * * * *
+
+ /**
+ * Removes XML-invalid characters from a string.
+ * @param {string} string - a string potentially containing XML-invalid characters, such as non-UTF8 characters, STX, EOX and so on.
+ * @param {boolean} removeDiscouragedChars - a string potentially containing XML-invalid characters, such as non-UTF8 characters, STX, EOX and so on.
+ * @returns : a sanitized string without all the XML-invalid characters.
+ *
+ * Source: https://www.ryadel.com/en/javascript-remove-xml-invalid-chars-characters-string-utf8-unicode-regex/
+ */
+ removeXMLInvalidChars: function (string, removeDiscouragedChars = true)
+ {
+ // remove everything forbidden by XML 1.0 specifications, plus the unicode replacement character U+FFFD
+ var regex = /((?:[\0-\x08\x0B\f\x0E-\x1F\uFFFD\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]))/g;
+ string = string.replace(regex, "");
+
+ if (removeDiscouragedChars) {
+ // remove everything not suggested by XML 1.0 specifications
+ regex = new RegExp(
+ "([\\x7F-\\x84]|[\\x86-\\x9F]|[\\uFDD0-\\uFDEF]|(?:\\uD83F[\\uDFFE\\uDFFF])|(?:\\uD87F[\\uDF"+
+ "FE\\uDFFF])|(?:\\uD8BF[\\uDFFE\\uDFFF])|(?:\\uD8FF[\\uDFFE\\uDFFF])|(?:\\uD93F[\\uDFFE\\uD"+
+ "FFF])|(?:\\uD97F[\\uDFFE\\uDFFF])|(?:\\uD9BF[\\uDFFE\\uDFFF])|(?:\\uD9FF[\\uDFFE\\uDFFF])"+
+ "|(?:\\uDA3F[\\uDFFE\\uDFFF])|(?:\\uDA7F[\\uDFFE\\uDFFF])|(?:\\uDABF[\\uDFFE\\uDFFF])|(?:\\"+
+ "uDAFF[\\uDFFE\\uDFFF])|(?:\\uDB3F[\\uDFFE\\uDFFF])|(?:\\uDB7F[\\uDFFE\\uDFFF])|(?:\\uDBBF"+
+ "[\\uDFFE\\uDFFF])|(?:\\uDBFF[\\uDFFE\\uDFFF])(?:[\\0-\\t\\x0B\\f\\x0E-\\u2027\\u202A-\\uD7FF\\"+
+ "uE000-\\uFFFF]|[\\uD800-\\uDBFF][\\uDC00-\\uDFFF]|[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|"+
+ "(?:[^\\uD800-\\uDBFF]|^)[\\uDC00-\\uDFFF]))", "g");
+ string = string.replace(regex, "");
+ }
+
+ return string;
+ },
+
+ xmlns: function (ns) {
+ let _xmlns = [];
+ for (let i=0; i < ns.length; i++) {
+ _xmlns.push('xmlns:'+ns[i]+'="'+dav.sync.ns[ns[i]]+'"');
+ }
+ return _xmlns.join(" ");
+ },
+
+ parseUri: function (aUri) {
+ let uri;
+ try {
+ // Test if the entered uri can be parsed.
+ uri = Services.io.newURI(aUri, null, null);
+ } catch (ex) {
+ throw new Error("invalid-calendar-url");
+ }
+ return uri;
+ },
+
+ parseVcardDateTime: function ( newServerValue, metadata ) {
+ if (!newServerValue) {
+ return false;
+ }
+
+ /*
+ ** This accepts RFC2426 BDAY values (with/without hyphens),
+ ** though TB doesn't handle the time part of date-times, so we discard it.
+ */
+ let bday = newServerValue.match( /^(\d{4})-?(\d{2})-?(\d{2})/ );
+ if (!bday) {
+ return false;
+ }
+
+ /*
+ ** Apple Contacts shoehorns date with missing year into vcard3 thus: BDAY;X-APPLE-OMIT-YEAR=1604:1604-03-15
+ ** Later in vcard4, it will be represented as BDAY:--0315
+ */
+ if (metadata
+ && metadata['x-apple-omit-year']
+ && metadata['x-apple-omit-year'] == bday[1]) {
+ bday[1] = '';
+ }
+ return bday;
+ },
+
+ //* * * * * * * * * * * * * *
+ //* EVALUATE XML RESPONSES *
+ //* * * * * * * * * * * * * *
+
+ convertToXML: function(text) {
+ //try to convert response body to xml
+ let xml = null;
+ let oParser = new DOMParser();
+ try {
+ xml = oParser.parseFromString(dav.tools.removeXMLInvalidChars(text), "application/xml");
+ } catch (e) {
+ //however, domparser does not throw an error, it returns an error document
+ //https://developer.mozilla.org/de/docs/Web/API/DOMParser
+ xml = null;
+ }
+ //check if xml is error document
+ if (xml && xml.documentElement.nodeName == "parsererror") {
+ xml = null;
+ }
+
+ return xml;
+ },
+
+ evaluateNode: function (_node, path) {
+ let node = _node;
+ let valid = false;
+
+ for (let i=0; i < path.length; i++) {
+
+ let children = node.children;
+ valid = false;
+
+ for (let c=0; c < children.length; c++) {
+ if (children[c].localName == path[i][1] && children[c].namespaceURI == dav.sync.ns[path[i][0]]) {
+ node = children[c];
+ valid = true;
+ break;
+ }
+ }
+
+ if (!valid) {
+ //none of the children matched the path abort
+ return null;
+ }
+ }
+
+ if (valid) return node;
+ return null;
+ },
+
+ hrefMatch:function (_requestHref, _responseHref) {
+ if (_requestHref === null)
+ return true;
+
+ let requestHref = _requestHref;
+ let responseHref = _responseHref;
+ while (requestHref.endsWith("/")) { requestHref = requestHref.slice(0,-1); }
+ while (responseHref.endsWith("/")) { responseHref = responseHref.slice(0,-1); }
+ if (requestHref.endsWith(responseHref) || decodeURIComponent(requestHref).endsWith(responseHref) || requestHref.endsWith(decodeURIComponent(responseHref)))
+ return true;
+
+ return false;
+ },
+
+ getNodeTextContentFromMultiResponse: function (response, path, href = null, status = ["200"]) {
+ for (let i=0; i < response.multi.length; i++) {
+ let node = dav.tools.evaluateNode(response.multi[i].node, path);
+ if (node !== null && dav.tools.hrefMatch(href, response.multi[i].href) && status.includes(response.multi[i].status)) {
+ return node.textContent;
+ }
+ }
+ return null;
+ },
+
+ getNodesTextContentFromMultiResponse: function (response, path, href = null, status = "200") {
+ //remove last element from path
+ let lastPathElement = path.pop();
+ let rv = [];
+
+ for (let i=0; i < response.multi.length; i++) {
+ let node = dav.tools.evaluateNode(response.multi[i].node, path);
+ if (node !== null && dav.tools.hrefMatch(href, response.multi[i].href) && response.multi[i].status == status) {
+
+ //get all children
+ let children = node.getElementsByTagNameNS(dav.sync.ns[lastPathElement[0]], lastPathElement[1]);
+ for (let c=0; c < children.length; c++) {
+ if (children[c].textContent) rv.push(children[c].textContent);
+ }
+ }
+ }
+ return rv;
+ },
+
+ getMultiGetRequest: function(hrefs) {
+ let request = "<card:addressbook-multiget "+dav.tools.xmlns(["d", "card"])+"><d:prop><d:getetag /><card:address-data /></d:prop>";
+ let counts = 0;
+ for (let i=0; i < hrefs.length; i++) {
+ request += "<d:href>"+hrefs[i]+"</d:href>";
+ counts++;
+ }
+ request += "</card:addressbook-multiget>";
+
+ if (counts > 0) return request;
+ else return null;
+ },
+}