diff options
Diffstat (limited to 'content/includes')
-rw-r--r-- | content/includes/network.js | 431 | ||||
-rw-r--r-- | content/includes/sync.js | 462 | ||||
-rw-r--r-- | content/includes/tools.js | 198 |
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; + }, +} |