summaryrefslogtreecommitdiffstats
path: root/content/includes/sync.js
blob: 5a73bb356adc8d11c370ca251ac5ab2ea59022e6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
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;
        }
    },

}