webdav.js (40235B)
1 /* 2 ***** BEGIN LICENSE BLOCK ***** 3 4 Copyright © 2009 Center for History and New Media 5 George Mason University, Fairfax, Virginia, USA 6 http://zotero.org 7 8 This file is part of Zotero. 9 10 Zotero is free software: you can redistribute it and/or modify 11 it under the terms of the GNU Affero General Public License as published by 12 the Free Software Foundation, either version 3 of the License, or 13 (at your option) any later version. 14 15 Zotero is distributed in the hope that it will be useful, 16 but WITHOUT ANY WARRANTY; without even the implied warranty of 17 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 GNU Affero General Public License for more details. 19 20 You should have received a copy of the GNU Affero General Public License 21 along with Zotero. If not, see <http://www.gnu.org/licenses/>. 22 23 ***** END LICENSE BLOCK ***** 24 */ 25 26 27 if (!Zotero.Sync.Storage.Mode) { 28 Zotero.Sync.Storage.Mode = {}; 29 } 30 31 Zotero.Sync.Storage.Mode.WebDAV = function (options) { 32 this.options = options; 33 34 this.VerificationError = function (error, uri) { 35 this.message = `WebDAV verification error (${error})`; 36 this.error = error; 37 this.uri = uri; 38 } 39 this.VerificationError.prototype = Object.create(Error.prototype); 40 } 41 Zotero.Sync.Storage.Mode.WebDAV.prototype = { 42 mode: "webdav", 43 name: "WebDAV", 44 45 get verified() { 46 return Zotero.Prefs.get("sync.storage.verified"); 47 }, 48 set verified(val) { 49 Zotero.Prefs.set("sync.storage.verified", !!val) 50 }, 51 52 _initialized: false, 53 _parentURI: null, 54 _rootURI: null, 55 _cachedCredentials: false, 56 57 _loginManagerHost: 'chrome://zotero', 58 _loginManagerRealm: 'Zotero Storage Server', 59 60 61 get defaultError() { 62 return Zotero.getString('sync.storage.error.webdav.default'); 63 }, 64 65 get defaultErrorRestart() { 66 return Zotero.getString('sync.storage.error.webdav.defaultRestart', Zotero.appName); 67 }, 68 69 get username() { 70 return Zotero.Prefs.get('sync.storage.username'); 71 }, 72 73 get password() { 74 var username = this.username; 75 76 if (!username) { 77 Zotero.debug('Username not set before getting Zotero.Sync.Storage.WebDAV.password'); 78 return ''; 79 } 80 81 Zotero.debug('Getting WebDAV password'); 82 var loginManager = Components.classes["@mozilla.org/login-manager;1"] 83 .getService(Components.interfaces.nsILoginManager); 84 85 var logins = loginManager.findLogins({}, this._loginManagerHost, null, this._loginManagerRealm); 86 // Find user from returned array of nsILoginInfo objects 87 for (var i = 0; i < logins.length; i++) { 88 if (logins[i].username == username) { 89 return logins[i].password; 90 } 91 } 92 93 // Pre-4.0.28.5 format, broken for findLogins and removeLogin in Fx41 94 logins = loginManager.findLogins({}, "chrome://zotero", "", null); 95 for (var i = 0; i < logins.length; i++) { 96 if (logins[i].username == username 97 && logins[i].formSubmitURL == "Zotero Storage Server") { 98 return logins[i].password; 99 } 100 } 101 102 return ''; 103 }, 104 105 set password(password) { 106 var username = this.username; 107 if (!username) { 108 Zotero.debug('WebDAV username not set before setting password'); 109 return; 110 } 111 112 if (password == this.password) { 113 Zotero.debug("WebDAV password hasn't changed"); 114 return; 115 } 116 117 _cachedCredentials = false; 118 119 var loginManager = Components.classes["@mozilla.org/login-manager;1"] 120 .getService(Components.interfaces.nsILoginManager); 121 var logins = loginManager.findLogins({}, this._loginManagerHost, null, this._loginManagerRealm); 122 for (var i = 0; i < logins.length; i++) { 123 Zotero.debug('Clearing WebDAV passwords'); 124 if (logins[i].httpRealm == this._loginManagerRealm) { 125 loginManager.removeLogin(logins[i]); 126 } 127 break; 128 } 129 130 // Pre-4.0.28.5 format, broken for findLogins and removeLogin in Fx41 131 logins = loginManager.findLogins({}, this._loginManagerHost, "", null); 132 for (var i = 0; i < logins.length; i++) { 133 Zotero.debug('Clearing old WebDAV passwords'); 134 if (logins[i].formSubmitURL == "Zotero Storage Server") { 135 try { 136 loginManager.removeLogin(logins[i]); 137 } 138 catch (e) { 139 Zotero.logError(e); 140 } 141 } 142 break; 143 } 144 145 if (password) { 146 Zotero.debug('Setting WebDAV password'); 147 var nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1", 148 Components.interfaces.nsILoginInfo, "init"); 149 var loginInfo = new nsLoginInfo(this._loginManagerHost, null, 150 this._loginManagerRealm, username, password, "", ""); 151 loginManager.addLogin(loginInfo); 152 } 153 }, 154 155 get rootURI() { 156 if (!this._rootURI) { 157 this._init(); 158 } 159 return this._rootURI.clone(); 160 }, 161 162 get parentURI() { 163 if (!this._parentURI) { 164 this._init(); 165 } 166 return this._parentURI.clone(); 167 }, 168 169 _init: function () { 170 this._rootURI = false; 171 this._parentURI = false; 172 173 var scheme = Zotero.Prefs.get('sync.storage.scheme'); 174 switch (scheme) { 175 case 'http': 176 case 'https': 177 break; 178 179 default: 180 throw new Error("Invalid WebDAV scheme '" + scheme + "'"); 181 } 182 183 var url = Zotero.Prefs.get('sync.storage.url'); 184 if (!url) { 185 throw new this.VerificationError("NO_URL"); 186 } 187 188 url = scheme + '://' + url; 189 var dir = "zotero"; 190 var username = this.username; 191 var password = this.password; 192 193 if (!username) { 194 throw new this.VerificationError("NO_USERNAME"); 195 } 196 197 if (!password) { 198 throw new this.VerificationError("NO_PASSWORD"); 199 } 200 201 var ios = Components.classes["@mozilla.org/network/io-service;1"]. 202 getService(Components.interfaces.nsIIOService); 203 var uri = ios.newURI(url, null, null); 204 uri.username = encodeURIComponent(username); 205 uri.password = encodeURIComponent(password); 206 if (!uri.spec.match(/\/$/)) { 207 uri.spec += "/"; 208 } 209 this._parentURI = uri; 210 211 var uri = uri.clone(); 212 uri.spec += "zotero/"; 213 this._rootURI = uri; 214 }, 215 216 217 cacheCredentials: Zotero.Promise.coroutine(function* () { 218 if (this._cachedCredentials) { 219 Zotero.debug("WebDAV credentials are already cached"); 220 return; 221 } 222 223 Zotero.debug("Caching WebDAV credentials"); 224 225 try { 226 var req = yield Zotero.HTTP.request("OPTIONS", this.rootURI); 227 228 Zotero.debug("WebDAV credentials cached"); 229 this._cachedCredentials = true; 230 } 231 catch (e) { 232 if (e instanceof Zotero.HTTP.UnexpectedStatusException) { 233 let msg = "HTTP " + e.status + " error from WebDAV server " 234 + "for OPTIONS request"; 235 Zotero.logError(msg); 236 throw new Error(this.defaultErrorRestart); 237 } 238 throw e; 239 } 240 }), 241 242 243 clearCachedCredentials: function() { 244 this._rootURI = this._parentURI = undefined; 245 this._cachedCredentials = false; 246 }, 247 248 249 /** 250 * Begin download process for individual file 251 * 252 * @param {Zotero.Sync.Storage.Request} request 253 * @return {Promise<Zotero.Sync.Storage.Result>} 254 */ 255 downloadFile: Zotero.Promise.coroutine(function* (request) { 256 var item = Zotero.Sync.Storage.Utilities.getItemFromRequest(request); 257 if (!item) { 258 throw new Error("Item '" + request.name + "' not found"); 259 } 260 261 // Skip download if local file exists and matches mod time 262 var path = item.getFilePath(); 263 if (!path) { 264 Zotero.debug(`Cannot download file for attachment ${item.libraryKey} with no path`); 265 return new Zotero.Sync.Storage.Result; 266 } 267 268 // Retrieve modification time from server 269 var metadata = yield this._getStorageFileMetadata(item, request); 270 271 if (!request.isRunning()) { 272 Zotero.debug("Download request '" + request.name 273 + "' is no longer running after getting mod time"); 274 return new Zotero.Sync.Storage.Result; 275 } 276 277 if (!metadata) { 278 Zotero.debug("Remote file not found for item " + item.libraryKey); 279 return new Zotero.Sync.Storage.Result; 280 } 281 282 var fileModTime = yield item.attachmentModificationTime; 283 if (metadata.mtime == fileModTime) { 284 Zotero.debug("File mod time matches remote file -- skipping download of " 285 + item.libraryKey); 286 287 var updateItem = item.attachmentSyncState != 1 288 item.attachmentSyncedModificationTime = metadata.mtime; 289 item.attachmentSyncState = "in_sync"; 290 yield item.saveTx({ skipAll: true }); 291 // DEBUG: Necessary? 292 if (updateItem) { 293 yield item.updateSynced(false); 294 } 295 296 return new Zotero.Sync.Storage.Result({ 297 localChanges: true, // ? 298 }); 299 } 300 301 var uri = this._getItemURI(item); 302 303 var destPath = OS.Path.join(Zotero.getTempDirectory().path, item.key + '.tmp'); 304 yield Zotero.File.removeIfExists(destPath); 305 306 var deferred = Zotero.Promise.defer(); 307 var requestData = { 308 item, 309 mtime: metadata.mtime, 310 md5: metadata.md5, 311 compressed: true 312 }; 313 314 var listener = new Zotero.Sync.Storage.StreamListener( 315 { 316 onStart: function (req) { 317 if (request.isFinished()) { 318 Zotero.debug("Download request " + request.name 319 + " stopped before download started -- closing channel"); 320 req.cancel(0x804b0002); // NS_BINDING_ABORTED 321 deferred.resolve(new Zotero.Sync.Storage.Result); 322 } 323 }, 324 onProgress: function (a, b, c) { 325 request.onProgress(a, b, c) 326 }, 327 onStop: Zotero.Promise.coroutine(function* (req, status, res) { 328 request.setChannel(false); 329 330 if (status == 404) { 331 let msg = "Remote ZIP file not found for item " + item.libraryKey; 332 Zotero.debug(msg, 2); 333 Components.utils.reportError(msg); 334 335 // Delete the orphaned prop file 336 try { 337 yield this._deleteStorageFiles([item.key + ".prop"]); 338 } 339 catch (e) { 340 Zotero.logError(e); 341 } 342 343 deferred.resolve(new Zotero.Sync.Storage.Result); 344 return; 345 } 346 else if (status != 200) { 347 try { 348 this._throwFriendlyError("GET", dispURL, status); 349 } 350 catch (e) { 351 deferred.reject(e); 352 } 353 return; 354 } 355 356 // Don't try to process if the request has been cancelled 357 if (request.isFinished()) { 358 Zotero.debug("Download request " + request.name 359 + " is no longer running after file download"); 360 deferred.resolve(new Zotero.Sync.Storage.Result); 361 return; 362 } 363 364 Zotero.debug("Finished download of " + destPath); 365 366 try { 367 deferred.resolve( 368 Zotero.Sync.Storage.Local.processDownload(requestData) 369 ); 370 } 371 catch (e) { 372 deferred.reject(e); 373 } 374 }.bind(this)), 375 onCancel: function (req, status) { 376 Zotero.debug("Request cancelled"); 377 if (deferred.promise.isPending()) { 378 deferred.resolve(new Zotero.Sync.Storage.Result); 379 } 380 } 381 } 382 ); 383 384 // Don't display password in console 385 var dispURL = Zotero.HTTP.getDisplayURI(uri).spec; 386 Zotero.debug('Saving ' + dispURL); 387 const nsIWBP = Components.interfaces.nsIWebBrowserPersist; 388 var wbp = Components.classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"] 389 .createInstance(nsIWBP); 390 wbp.persistFlags = nsIWBP.PERSIST_FLAGS_BYPASS_CACHE; 391 wbp.progressListener = listener; 392 Zotero.Utilities.Internal.saveURI(wbp, uri, destPath); 393 394 return deferred.promise; 395 }), 396 397 398 uploadFile: Zotero.Promise.coroutine(function* (request) { 399 var item = Zotero.Sync.Storage.Utilities.getItemFromRequest(request); 400 var params = { 401 mtime: yield item.attachmentModificationTime, 402 md5: yield item.attachmentHash 403 }; 404 405 var metadata = yield this._getStorageFileMetadata(item, request); 406 407 if (!request.isRunning()) { 408 Zotero.debug("Upload request '" + request.name 409 + "' is no longer running after getting metadata"); 410 return new Zotero.Sync.Storage.Result; 411 } 412 413 // Check if file already exists on WebDAV server 414 if (item.attachmentSyncState 415 != Zotero.Sync.Storage.Local.SYNC_STATE_FORCE_UPLOAD) { 416 if (metadata.mtime) { 417 // Local file time 418 let fmtime = yield item.attachmentModificationTime; 419 // Remote prop time 420 let mtime = metadata.mtime; 421 422 var changed = Zotero.Sync.Storage.Local.checkFileModTime(item, fmtime, mtime); 423 if (!changed) { 424 // Remote hash 425 let hash = metadata.md5; 426 if (hash) { 427 // Local file hash 428 let fhash = yield item.attachmentHash; 429 if (fhash != hash) { 430 changed = true; 431 } 432 } 433 434 // If WebDAV server already has file, update synced properties 435 if (!changed) { 436 item.attachmentSyncedModificationTime = fmtime; 437 if (hash) { 438 item.attachmentSyncedHash = hash; 439 } 440 item.attachmentSyncState = "in_sync"; 441 yield item.saveTx({ skipAll: true }); 442 // skipAll doesn't mark as unsynced, so do that separately 443 yield item.updateSynced(false); 444 return new Zotero.Sync.Storage.Result; 445 } 446 } 447 448 // Check for conflict between synced values and values on WebDAV server. This 449 // should almost never happen, but it's possible if a client uploaded to WebDAV 450 // but failed before updating the API (or the local properties if this computer), 451 // or if the file was changed identically on two computers at the same time, such 452 // that the post-upload API update on computer B happened after the pre-upload API 453 // check on computer A. (In the case of a failure, there's no guarantee that the 454 // API would ever be updated with the correct values, so we can't just wait for 455 // the API to change.) If a conflict is found, we flag the item as in conflict 456 // and require another file sync, which will trigger conflict resolution. 457 let smtime = item.attachmentSyncedModificationTime; 458 if (smtime != mtime) { 459 let shash = item.attachmentSyncedHash; 460 if (shash && metadata.md5 && shash == metadata.md5) { 461 Zotero.debug("Last synced mod time for item " + item.libraryKey 462 + " doesn't match time on storage server but hash does -- ignoring"); 463 return new Zotero.Sync.Storage.Result; 464 } 465 466 Zotero.logError("Conflict -- last synced file mod time for item " 467 + item.libraryKey + " does not match time on storage server" 468 + " (" + smtime + " != " + mtime + ")"); 469 470 // Conflict resolution uses the synced mtime as the remote value, so set 471 // that to the WebDAV value, since that's the one in conflict. 472 item.attachmentSyncedModificationTime = mtime; 473 item.attachmentSyncState = "in_conflict"; 474 yield item.saveTx({ skipAll: true }); 475 476 return new Zotero.Sync.Storage.Result({ 477 fileSyncRequired: true 478 }); 479 } 480 } 481 else { 482 Zotero.debug("Remote file not found for item " + item.id); 483 } 484 } 485 486 var created = yield Zotero.Sync.Storage.Utilities.createUploadFile(request); 487 if (!created) { 488 return new Zotero.Sync.Storage.Result; 489 } 490 491 /* 492 updateSizeMultiplier( 493 (100 - Zotero.Sync.Storage.compressionTracker.ratio) / 100 494 ); 495 */ 496 497 // Delete .prop file before uploading new .zip 498 if (metadata) { 499 var propURI = this._getItemPropertyURI(item); 500 try { 501 yield Zotero.HTTP.request( 502 "DELETE", 503 propURI, 504 { 505 successCodes: [200, 204, 404], 506 requestObserver: xmlhttp => request.setChannel(xmlhttp.channel), 507 debug: true 508 } 509 ); 510 } 511 catch (e) { 512 if (e instanceof Zotero.HTTP.UnexpectedStatusException) { 513 this._throwFriendlyError("DELETE", Zotero.HTTP.getDisplayURI(propURI).spec, e.status); 514 } 515 throw e; 516 } 517 } 518 519 var file = Zotero.getTempDirectory(); 520 file.append(item.key + '.zip'); 521 Components.utils.importGlobalProperties(["File"]); 522 file = File.createFromFileName ? File.createFromFileName(file.path) : new File(file); 523 // File.createFromFileName() returns a Promise in Fx54+ 524 if (file.then) { 525 file = yield file; 526 } 527 528 var uri = this._getItemURI(item); 529 530 try { 531 var req = yield Zotero.HTTP.request( 532 "PUT", 533 uri, 534 { 535 headers: { 536 "Content-Type": "application/zip" 537 }, 538 body: file, 539 requestObserver: function (req) { 540 request.setChannel(req.channel); 541 req.upload.addEventListener("progress", function (event) { 542 if (event.lengthComputable) { 543 request.onProgress(event.loaded, event.total); 544 } 545 }); 546 }, 547 debug: true 548 } 549 ); 550 } 551 catch (e) { 552 if (e instanceof Zotero.HTTP.UnexpectedStatusException) { 553 if (e.status == 507) { 554 throw new Error( 555 Zotero.getString('sync.storage.error.webdav.insufficientSpace') 556 ); 557 } 558 559 this._throwFriendlyError("PUT", Zotero.HTTP.getDisplayURI(uri).spec, e.status); 560 } 561 throw e; 562 563 // TODO: Detect cancel? 564 //onUploadCancel(httpRequest, status, data) 565 //deferred.resolve(false); 566 } 567 568 request.setChannel(false); 569 return this._onUploadComplete(req, request, item, params); 570 }), 571 572 573 /** 574 * @return {Promise} 575 * @throws {Zotero.Sync.Storage.Mode.WebDAV.VerificationError|Error} 576 */ 577 checkServer: Zotero.Promise.coroutine(function* (options = {}) { 578 // Clear URIs 579 this._init(); 580 581 var parentURI = this.parentURI; 582 var uri = this.rootURI; 583 584 var xmlstr = "<propfind xmlns='DAV:'><prop>" 585 // IIS 5.1 requires at least one property in PROPFIND 586 + "<getcontentlength/>" 587 + "</prop></propfind>"; 588 589 var channel; 590 var requestObserver = function (req) { 591 if (options.onRequest) { 592 options.onRequest(req); 593 } 594 } 595 596 // Test whether URL is WebDAV-enabled 597 var req = yield Zotero.HTTP.request( 598 "OPTIONS", 599 uri, 600 { 601 successCodes: [200, 404], 602 requestObserver: function (req) { 603 if (req.channel) { 604 channel = req.channel; 605 } 606 if (options.onRequest) { 607 options.onRequest(req); 608 } 609 }, 610 debug: true 611 } 612 ); 613 614 Zotero.debug(req.getAllResponseHeaders()); 615 616 var dav = req.getResponseHeader("DAV"); 617 if (dav == null) { 618 throw new this.VerificationError("NOT_DAV", uri); 619 } 620 621 var headers = { Depth: 0 }; 622 var contentTypeXML = { "Content-Type": "text/xml; charset=utf-8" }; 623 624 // Get the Authorization header used in case we need to do a request 625 // on the parent below 626 if (channel) { 627 var channelAuthorization = Zotero.HTTP.getChannelAuthorization(channel); 628 channel = null; 629 } 630 631 // Test whether Zotero directory exists 632 req = yield Zotero.HTTP.request("PROPFIND", uri, { 633 body: xmlstr, 634 headers: Object.assign({}, headers, contentTypeXML), 635 successCodes: [207, 404], 636 requestObserver, 637 debug: true 638 }); 639 640 if (req.status == 207) { 641 // Test if missing files return 404s 642 let missingFileURI = uri.clone(); 643 missingFileURI.spec += "nonexistent.prop"; 644 try { 645 req = yield Zotero.HTTP.request( 646 "GET", 647 missingFileURI, 648 { 649 successCodes: [404], 650 responseType: 'text', 651 requestObserver, 652 debug: true 653 } 654 ) 655 } 656 catch (e) { 657 if (e instanceof Zotero.HTTP.UnexpectedStatusException) { 658 if (e.status >= 200 && e.status < 300) { 659 throw this.VerificationError("NONEXISTENT_FILE_NOT_MISSING", uri); 660 } 661 } 662 throw e; 663 } 664 665 // Test if Zotero directory is writable 666 let testFileURI = uri.clone(); 667 testFileURI.spec += "zotero-test-file.prop"; 668 req = yield Zotero.HTTP.request("PUT", testFileURI, { 669 body: " ", 670 successCodes: [200, 201, 204], 671 requestObserver, 672 debug: true 673 }); 674 675 req = yield Zotero.HTTP.request( 676 "GET", 677 testFileURI, 678 { 679 successCodes: [200, 404], 680 responseType: 'text', 681 requestObserver, 682 debug: true 683 } 684 ); 685 686 if (req.status == 200) { 687 // Delete test file 688 yield Zotero.HTTP.request( 689 "DELETE", 690 testFileURI, 691 { 692 successCodes: [200, 204], 693 requestObserver, 694 debug: true 695 } 696 ); 697 } 698 // This can happen with cloud storage services backed by S3 or other eventually 699 // consistent data stores. 700 // 701 // This can also be from IIS 6+, which is configured not to serve .prop files. 702 // http://support.microsoft.com/kb/326965 703 else if (req.status == 404) { 704 throw new this.VerificationError("FILE_MISSING_AFTER_UPLOAD", uri); 705 } 706 } 707 else if (req.status == 404) { 708 // Include Authorization header from /zotero request, 709 // since Firefox probably won't apply it to the parent request 710 if (channelAuthorization) { 711 headers.Authorization = channelAuthorization; 712 } 713 714 // Zotero directory wasn't found, so see if at least 715 // the parent directory exists 716 req = yield Zotero.HTTP.request("PROPFIND", parentURI, { 717 headers: Object.assign({}, headers, contentTypeXML), 718 body: xmlstr, 719 requestObserver, 720 successCodes: [207, 404] 721 }); 722 723 if (req.status == 207) { 724 throw new this.VerificationError("ZOTERO_DIR_NOT_FOUND", uri); 725 } 726 else if (req.status == 404) { 727 throw new this.VerificationError("PARENT_DIR_NOT_FOUND", uri); 728 } 729 } 730 731 this.verified = true; 732 Zotero.debug(this.name + " file sync is successfully set up"); 733 }), 734 735 736 /** 737 * Handles the result of WebDAV verification, displaying an alert if necessary. 738 * 739 * @return bool True if the verification eventually succeeded, false otherwise 740 */ 741 handleVerificationError: Zotero.Promise.coroutine(function* (err, window, skipSuccessMessage) { 742 var promptService = 743 Components.classes["@mozilla.org/embedcomp/prompt-service;1"]. 744 createInstance(Components.interfaces.nsIPromptService); 745 var uri = err.uri; 746 if (uri) { 747 var spec = uri.scheme + '://' + uri.hostPort + uri.path; 748 } 749 750 var errorTitle, errorMsg; 751 752 if (err instanceof Zotero.HTTP.UnexpectedStatusException) { 753 switch (err.status) { 754 case 0: 755 errorMsg = Zotero.getString('sync.storage.error.serverCouldNotBeReached', err.channel.URI.host); 756 break; 757 758 case 401: 759 errorTitle = Zotero.getString('general.permissionDenied'); 760 errorMsg = Zotero.getString('sync.storage.error.webdav.invalidLogin') + "\n\n" 761 + Zotero.getString('sync.storage.error.checkFileSyncSettings'); 762 break; 763 764 case 403: 765 errorTitle = Zotero.getString('general.permissionDenied'); 766 errorMsg = Zotero.getString('sync.storage.error.webdav.permissionDenied', err.channel.URI.path) 767 + "\n\n" + Zotero.getString('sync.storage.error.checkFileSyncSettings'); 768 break; 769 770 case 500: 771 errorTitle = Zotero.getString('sync.storage.error.webdav.serverConfig.title'); 772 errorMsg = Zotero.getString('sync.storage.error.webdav.serverConfig') 773 + "\n\n" + Zotero.getString('sync.storage.error.checkFileSyncSettings'); 774 break; 775 776 default: 777 errorMsg = Zotero.getString('general.unknownErrorOccurred') + "\n\n" 778 + Zotero.getString('sync.storage.error.checkFileSyncSettings') + "\n\n" 779 + "HTTP " + err.status; 780 break; 781 } 782 } 783 else if (err instanceof this.VerificationError) { 784 switch (err.error) { 785 case "NO_URL": 786 errorMsg = Zotero.getString('sync.storage.error.webdav.enterURL'); 787 break; 788 789 case "NO_USERNAME": 790 errorMsg = Zotero.getString('sync.error.usernameNotSet'); 791 break; 792 793 case "NO_PASSWORD": 794 errorMsg = Zotero.getString('sync.error.enterPassword'); 795 break; 796 797 case "NOT_DAV": 798 errorMsg = Zotero.getString('sync.storage.error.webdav.invalidURL', spec); 799 break; 800 801 case "PARENT_DIR_NOT_FOUND": 802 errorTitle = Zotero.getString('sync.storage.error.directoryNotFound'); 803 var parentSpec = spec.replace(/\/zotero\/$/, ""); 804 errorMsg = Zotero.getString('sync.storage.error.doesNotExist', parentSpec); 805 break; 806 807 case "ZOTERO_DIR_NOT_FOUND": 808 var create = promptService.confirmEx( 809 window, 810 Zotero.getString('sync.storage.error.directoryNotFound'), 811 Zotero.getString('sync.storage.error.doesNotExist', spec) + "\n\n" 812 + Zotero.getString('sync.storage.error.createNow'), 813 promptService.BUTTON_POS_0 814 * promptService.BUTTON_TITLE_IS_STRING 815 + promptService.BUTTON_POS_1 816 * promptService.BUTTON_TITLE_CANCEL, 817 Zotero.getString('general.create'), 818 null, null, null, {} 819 ); 820 821 if (create != 0) { 822 return; 823 } 824 825 try { 826 yield this._createServerDirectory(); 827 } 828 catch (e) { 829 if (e instanceof Zotero.HTTP.UnexpectedStatusException) { 830 if (e.status == 403) { 831 errorTitle = Zotero.getString('general.permissionDenied'); 832 let rootURI = this.rootURI; 833 let rootSpec = rootURI.scheme + '://' + rootURI.hostPort + rootURI.path 834 errorMsg = Zotero.getString('sync.storage.error.permissionDeniedAtAddress') 835 + "\n\n" + rootSpec + "\n\n" 836 + Zotero.getString('sync.storage.error.checkFileSyncSettings'); 837 break; 838 } 839 } 840 errorMsg = e; 841 break; 842 } 843 844 try { 845 yield this.checkServer(); 846 return true; 847 } 848 catch (e) { 849 return this.handleVerificationError(e, window, skipSuccessMessage); 850 } 851 break; 852 853 case "FILE_MISSING_AFTER_UPLOAD": 854 errorTitle = Zotero.getString("general.warning"); 855 errorMsg = Zotero.getString('sync.storage.error.webdav.fileMissingAfterUpload'); 856 Zotero.Prefs.set("sync.storage.verified", true); 857 break; 858 859 case "NONEXISTENT_FILE_NOT_MISSING": 860 errorTitle = Zotero.getString('sync.storage.error.webdav.serverConfig.title'); 861 errorMsg = Zotero.getString('sync.storage.error.webdav.nonexistentFileNotMissing'); 862 break; 863 864 default: 865 errorMsg = Zotero.getString('general.unknownErrorOccurred') + "\n\n" 866 Zotero.getString('sync.storage.error.checkFileSyncSettings'); 867 break; 868 } 869 } 870 871 var e; 872 if (errorMsg) { 873 e = { 874 message: errorMsg, 875 // Prevent Report Errors button for known errors 876 dialogButtonText: null 877 }; 878 Zotero.logError(errorMsg); 879 } 880 else { 881 e = err; 882 Zotero.logError(err); 883 } 884 885 if (!skipSuccessMessage) { 886 if (!errorTitle) { 887 errorTitle = Zotero.getString("general.error"); 888 } 889 Zotero.Utilities.Internal.errorPrompt(errorTitle, e); 890 } 891 return false; 892 }), 893 894 895 /** 896 * Remove files on storage server that were deleted locally 897 * 898 * @param {Integer} libraryID 899 */ 900 purgeDeletedStorageFiles: Zotero.Promise.coroutine(function* (libraryID) { 901 var d = new Date(); 902 903 Zotero.debug("Purging deleted storage files"); 904 var files = yield Zotero.Sync.Storage.Local.getDeletedFiles(libraryID); 905 if (!files.length) { 906 Zotero.debug("No files to delete remotely"); 907 return false; 908 } 909 910 // Add .zip extension 911 var files = files.map(file => file + ".zip"); 912 913 var results = yield this._deleteStorageFiles(files) 914 915 // Remove deleted and nonexistent files from storage delete log 916 var toPurge = Zotero.Utilities.arrayUnique( 917 results.deleted.concat(results.missing) 918 // Strip file extension so we just have keys 919 .map(val => val.replace(/\.(prop|zip)$/, "")) 920 ); 921 if (toPurge.length > 0) { 922 yield Zotero.Utilities.Internal.forEachChunkAsync( 923 toPurge, 924 Zotero.DB.MAX_BOUND_PARAMETERS - 1, 925 function (chunk) { 926 return Zotero.DB.executeTransaction(function* () { 927 var sql = "DELETE FROM storageDeleteLog WHERE libraryID=? AND key IN (" 928 + chunk.map(() => '?').join() + ")"; 929 return Zotero.DB.queryAsync(sql, [libraryID].concat(chunk)); 930 }); 931 } 932 ); 933 } 934 935 Zotero.debug(`Purged deleted storage files in ${new Date() - d} ms`); 936 Zotero.debug(results); 937 938 return results; 939 }), 940 941 942 /** 943 * Delete orphaned storage files older than a week before last sync time 944 */ 945 purgeOrphanedStorageFiles: Zotero.Promise.coroutine(function* () { 946 var d = new Date(); 947 const libraryID = Zotero.Libraries.userLibraryID; 948 const library = Zotero.Libraries.get(libraryID); 949 const daysBeforeSyncTime = 7; 950 951 // If recently purged, skip 952 var lastPurge = Zotero.Prefs.get('lastWebDAVOrphanPurge'); 953 if (lastPurge) { 954 try { 955 let purgeAfter = lastPurge + (daysBeforeSyncTime * 24 * 60 * 60); 956 if (new Date() < new Date(purgeAfter * 1000)) { 957 return false; 958 } 959 } 960 catch (e) { 961 Zotero.Prefs.clear('lastWebDAVOrphanPurge'); 962 } 963 } 964 965 Zotero.debug("Purging orphaned storage files"); 966 967 var uri = this.rootURI; 968 var path = uri.path; 969 970 var contentTypeXML = { "Content-Type": "text/xml; charset=utf-8" }; 971 var xmlstr = "<propfind xmlns='DAV:'><prop>" 972 + "<getlastmodified/>" 973 + "</prop></propfind>"; 974 975 var lastSyncDate = library.lastSync; 976 if (!lastSyncDate) { 977 Zotero.debug(`No last sync date for library ${libraryID} -- not purging orphaned files`); 978 return false; 979 } 980 981 var req = yield Zotero.HTTP.request( 982 "PROPFIND", 983 uri, 984 { 985 body: xmlstr, 986 headers: Object.assign({ Depth: 1 }, contentTypeXML), 987 successCodes: [207], 988 debug: true 989 } 990 ); 991 992 var responseNode = req.responseXML.documentElement; 993 responseNode.xpath = function (path) { 994 return Zotero.Utilities.xpath(this, path, { D: 'DAV:' }); 995 }; 996 997 var syncQueueKeys = new Set( 998 yield Zotero.Sync.Data.Local.getObjectsFromSyncQueue('item', libraryID) 999 ); 1000 var deleteFiles = []; 1001 var trailingSlash = !!path.match(/\/$/); 1002 for (let response of responseNode.xpath("D:response")) { 1003 var href = Zotero.Utilities.xpathText( 1004 response, "D:href", { D: 'DAV:' } 1005 ) || ""; 1006 Zotero.debug("Checking response entry " + href); 1007 1008 // Strip trailing slash if there isn't one on the root path 1009 if (!trailingSlash) { 1010 href = href.replace(/\/$/, ""); 1011 } 1012 1013 // Absolute 1014 if (href.match(/^https?:\/\//)) { 1015 let ios = Components.classes["@mozilla.org/network/io-service;1"] 1016 .getService(Components.interfaces.nsIIOService); 1017 href = ios.newURI(href, null, null).path; 1018 } 1019 1020 let decodedHref = decodeURIComponent(href).normalize(); 1021 let decodedPath = decodeURIComponent(path).normalize(); 1022 1023 // Skip root URI 1024 if (decodedHref == decodedPath 1025 // Some Apache servers respond with a "/zotero" href 1026 // even for a "/zotero/" request 1027 || (trailingSlash && decodedHref + '/' == decodedPath)) { 1028 continue; 1029 } 1030 1031 if (!decodedHref.startsWith(decodedPath)) { 1032 throw new Error(`DAV:href '${href}' does not begin with path '${path}'`); 1033 } 1034 1035 var matches = href.match(/[^\/]+$/); 1036 if (!matches) { 1037 throw new Error(`Unexpected href '${href}'`); 1038 } 1039 var file = matches[0]; 1040 1041 if (file.startsWith('.')) { 1042 Zotero.debug("Skipping hidden file " + file); 1043 continue; 1044 } 1045 1046 var isLastSyncFile = file == 'lastsync.txt' || file == 'lastsync'; 1047 if (!isLastSyncFile) { 1048 if (!file.endsWith('.zip') && !file.endsWith('.prop')) { 1049 Zotero.debug("Skipping file " + file); 1050 continue; 1051 } 1052 1053 let key = file.replace(/\.(zip|prop)$/, ''); 1054 let item = yield Zotero.Items.getByLibraryAndKeyAsync(libraryID, key); 1055 if (item) { 1056 Zotero.debug("Skipping existing file " + file); 1057 continue; 1058 } 1059 1060 if (syncQueueKeys.has(key)) { 1061 Zotero.debug(`Skipping file for item ${key} in sync queue`); 1062 continue; 1063 } 1064 } 1065 1066 Zotero.debug("Checking orphaned file " + file); 1067 1068 // TODO: Parse HTTP date properly 1069 Zotero.debug(response.innerHTML); 1070 var lastModified = Zotero.Utilities.xpathText( 1071 response, ".//D:getlastmodified", { D: 'DAV:' } 1072 ); 1073 lastModified = Zotero.Date.strToISO(lastModified); 1074 lastModified = Zotero.Date.sqlToDate(lastModified, true); 1075 1076 // Delete files older than a week before last sync time 1077 var days = (lastSyncDate - lastModified) / 1000 / 60 / 60 / 24; 1078 1079 if (days > daysBeforeSyncTime) { 1080 deleteFiles.push(file); 1081 } 1082 } 1083 1084 var results = yield this._deleteStorageFiles(deleteFiles); 1085 Zotero.Prefs.set("lastWebDAVOrphanPurge", Math.round(new Date().getTime() / 1000)); 1086 1087 Zotero.debug(`Purged orphaned storage files in ${new Date() - d} ms`); 1088 Zotero.debug(results); 1089 1090 return results; 1091 }), 1092 1093 1094 // 1095 // Private methods 1096 // 1097 /** 1098 * Get mod time and hash of file on storage server 1099 * 1100 * @param {Zotero.Item} item 1101 * @param {Zotero.Sync.Storage.Request} request 1102 * @return {Object} - Object with 'mtime' and 'md5' 1103 */ 1104 _getStorageFileMetadata: Zotero.Promise.coroutine(function* (item, request) { 1105 var uri = this._getItemPropertyURI(item); 1106 1107 try { 1108 var req = yield Zotero.HTTP.request( 1109 "GET", 1110 uri, 1111 { 1112 successCodes: [200, 300, 404], 1113 responseType: 'text', 1114 requestObserver: xmlhttp => request.setChannel(xmlhttp.channel), 1115 dontCache: true, 1116 debug: true 1117 } 1118 ); 1119 } 1120 catch (e) { 1121 if (e instanceof Zotero.HTTP.UnexpectedStatusException) { 1122 this._throwFriendlyError("GET", Zotero.HTTP.getDisplayURI(uri).spec, e.status); 1123 } 1124 throw e; 1125 } 1126 1127 // mod_speling can return 300s for 404s with base name matches 1128 if (req.status == 404 || req.status == 300) { 1129 return false; 1130 } 1131 1132 // No metadata set 1133 if (!req.responseText) { 1134 return false; 1135 } 1136 1137 var seconds = false; 1138 var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"] 1139 .createInstance(Components.interfaces.nsIDOMParser); 1140 try { 1141 var xml = parser.parseFromString(req.responseText, "text/xml"); 1142 } 1143 catch (e) { 1144 Zotero.logError(e); 1145 } 1146 1147 var mtime = false; 1148 var md5 = false; 1149 1150 if (xml) { 1151 try { 1152 var mtime = xml.getElementsByTagName('mtime')[0].textContent; 1153 } 1154 catch (e) {} 1155 try { 1156 var md5 = xml.getElementsByTagName('hash')[0].textContent; 1157 } 1158 catch (e) {} 1159 } 1160 1161 // TEMP: Accept old non-XML prop files with just mtimes in seconds 1162 if (!mtime) { 1163 mtime = req.responseText; 1164 seconds = true; 1165 } 1166 1167 var invalid = false; 1168 1169 // Unix timestamps need to be converted to ms-based timestamps 1170 if (seconds) { 1171 if (mtime.match(/^[0-9]{1,10}$/)) { 1172 Zotero.debug("Converting Unix timestamp '" + mtime + "' to milliseconds"); 1173 mtime = mtime * 1000; 1174 } 1175 else { 1176 invalid = true; 1177 } 1178 } 1179 else if (!mtime.match(/^[0-9]{1,13}$/)) { 1180 invalid = true; 1181 } 1182 1183 // Delete invalid .prop files 1184 if (invalid) { 1185 let msg = "Invalid mod date '" + Zotero.Utilities.ellipsize(mtime, 20) 1186 + "' for item " + item.libraryKey; 1187 Zotero.logError(msg); 1188 yield this._deleteStorageFiles([item.key + ".prop"]).catch(function (e) { 1189 Zotero.logError(e); 1190 }); 1191 throw new Error(Zotero.Sync.Storage.Mode.WebDAV.defaultError); 1192 } 1193 1194 return { 1195 mtime: parseInt(mtime), 1196 md5 1197 }; 1198 }), 1199 1200 1201 /** 1202 * Set mod time and hash of file on storage server 1203 * 1204 * @param {Zotero.Item} item 1205 */ 1206 _setStorageFileMetadata: Zotero.Promise.coroutine(function* (item) { 1207 var uri = this._getItemPropertyURI(item); 1208 1209 var mtime = yield item.attachmentModificationTime; 1210 var md5 = yield item.attachmentHash; 1211 1212 var xmlstr = '<properties version="1">' 1213 + '<mtime>' + mtime + '</mtime>' 1214 + '<hash>' + md5 + '</hash>' 1215 + '</properties>'; 1216 1217 try { 1218 yield Zotero.HTTP.request( 1219 "PUT", 1220 uri, 1221 { 1222 headers: { 1223 "Content-Type": "text/xml" 1224 }, 1225 body: xmlstr, 1226 successCodes: [200, 201, 204], 1227 debug: true 1228 } 1229 ) 1230 } 1231 catch (e) { 1232 if (e instanceof Zotero.HTTP.UnexpectedStatusException) { 1233 this._throwFriendlyError("PUT", Zotero.HTTP.getDisplayURI(uri).spec, e.status); 1234 } 1235 throw e; 1236 } 1237 }), 1238 1239 1240 _onUploadComplete: Zotero.Promise.coroutine(function* (req, request, item, params) { 1241 Zotero.debug("Upload of attachment " + item.key + " finished with status code " + req.status); 1242 Zotero.debug(req.responseText); 1243 1244 // Update .prop file on WebDAV server 1245 yield this._setStorageFileMetadata(item); 1246 1247 item.attachmentSyncedModificationTime = params.mtime; 1248 item.attachmentSyncedHash = params.md5; 1249 item.attachmentSyncState = "in_sync"; 1250 yield item.saveTx({ skipAll: true }); 1251 // skipAll doesn't mark as unsynced, so do that separately 1252 yield item.updateSynced(false); 1253 1254 try { 1255 yield OS.File.remove( 1256 OS.Path.join(Zotero.getTempDirectory().path, item.key + '.zip') 1257 ); 1258 } 1259 catch (e) { 1260 Zotero.logError(e); 1261 } 1262 1263 return new Zotero.Sync.Storage.Result({ 1264 localChanges: true, 1265 remoteChanges: true, 1266 syncRequired: true 1267 }); 1268 }), 1269 1270 1271 _onUploadCancel: function (httpRequest, status, data) { 1272 var request = data.request; 1273 var item = data.item; 1274 1275 Zotero.debug("Upload of attachment " + item.key + " cancelled with status code " + status); 1276 1277 try { 1278 var file = Zotero.getTempDirectory(); 1279 file.append(item.key + '.zip'); 1280 file.remove(false); 1281 } 1282 catch (e) { 1283 Components.utils.reportError(e); 1284 } 1285 }, 1286 1287 1288 /** 1289 * Create a Zotero directory on the storage server 1290 */ 1291 _createServerDirectory: function () { 1292 return Zotero.HTTP.request( 1293 "MKCOL", 1294 this.rootURI, 1295 { 1296 successCodes: [201] 1297 } 1298 ); 1299 }, 1300 1301 1302 /** 1303 * Get the storage URI for an item 1304 * 1305 * @inner 1306 * @param {Zotero.Item} 1307 * @return {nsIURI} URI of file on storage server 1308 */ 1309 _getItemURI: function (item) { 1310 var uri = this.rootURI; 1311 uri.spec = uri.spec + item.key + '.zip'; 1312 return uri; 1313 }, 1314 1315 1316 /** 1317 * Get the storage property file URI for an item 1318 * 1319 * @inner 1320 * @param {Zotero.Item} 1321 * @return {nsIURI} URI of property file on storage server 1322 */ 1323 _getItemPropertyURI: function (item) { 1324 var uri = this.rootURI; 1325 uri.spec = uri.spec + item.key + '.prop'; 1326 return uri; 1327 }, 1328 1329 1330 /** 1331 * Get the storage property file URI corresponding to a given item storage URI 1332 * 1333 * @param {nsIURI} Item storage URI 1334 * @return {nsIURI|FALSE} Property file URI, or FALSE if not an item storage URI 1335 */ 1336 _getPropertyURIFromItemURI: function (uri) { 1337 if (!uri.spec.match(/\.zip$/)) { 1338 return false; 1339 } 1340 var propURI = uri.clone(); 1341 propURI.QueryInterface(Components.interfaces.nsIURL); 1342 propURI.fileName = uri.fileName.replace(/\.zip$/, '.prop'); 1343 propURI.QueryInterface(Components.interfaces.nsIURI); 1344 return propURI; 1345 }, 1346 1347 1348 /** 1349 * @inner 1350 * @param {String[]} files - Filenames of files to delete 1351 * @return {Object} - Object with properties 'deleted', 'missing', and 'error', each 1352 * each containing filenames 1353 */ 1354 _deleteStorageFiles: Zotero.Promise.coroutine(function* (files) { 1355 var results = { 1356 deleted: new Set(), 1357 missing: new Set(), 1358 error: new Set() 1359 }; 1360 1361 if (files.length == 0) { 1362 return results; 1363 } 1364 1365 // Delete .prop files first 1366 files.sort(function (a, b) { 1367 if (a.endsWith('.zip') && b.endsWith('.prop')) return 1; 1368 if (b.endsWith('.zip') && a.endsWith('.prop')) return 1; 1369 return 0; 1370 }); 1371 1372 let deleteURI = this.rootURI.clone(); 1373 // This should never happen, but let's be safe 1374 if (!deleteURI.spec.match(/\/$/)) { 1375 throw new Error("Root URI does not end in slash"); 1376 } 1377 1378 var funcs = []; 1379 for (let i = 0 ; i < files.length; i++) { 1380 let fileName = files[i]; 1381 funcs.push(Zotero.Promise.coroutine(function* () { 1382 var deleteURI = this.rootURI.clone(); 1383 deleteURI.QueryInterface(Components.interfaces.nsIURL); 1384 deleteURI.fileName = fileName; 1385 deleteURI.QueryInterface(Components.interfaces.nsIURI); 1386 try { 1387 var req = yield Zotero.HTTP.request( 1388 "DELETE", 1389 deleteURI, 1390 { 1391 successCodes: [200, 204, 404] 1392 } 1393 ); 1394 } 1395 catch (e) { 1396 results.error.add(fileName); 1397 throw e; 1398 } 1399 1400 switch (req.status) { 1401 case 204: 1402 // IIS 5.1 and Sakai return 200 1403 case 200: 1404 results.deleted.add(fileName); 1405 break; 1406 1407 case 404: 1408 results.missing.add(fileName); 1409 break; 1410 } 1411 1412 // If an item file URI, get the property URI 1413 var deletePropURI = this._getPropertyURIFromItemURI(deleteURI); 1414 1415 // If we already deleted the prop file, skip it 1416 if (!deletePropURI || results.deleted.has(deletePropURI.fileName)) { 1417 return; 1418 } 1419 1420 fileName = deletePropURI.fileName; 1421 1422 // Delete property file 1423 var req = yield Zotero.HTTP.request( 1424 "DELETE", 1425 deletePropURI, 1426 { 1427 successCodes: [200, 204, 404] 1428 } 1429 ); 1430 switch (req.status) { 1431 case 204: 1432 // IIS 5.1 and Sakai return 200 1433 case 200: 1434 results.deleted.add(fileName); 1435 break; 1436 1437 case 404: 1438 results.missing.add(fileName); 1439 break; 1440 } 1441 }.bind(this))); 1442 } 1443 1444 Components.utils.import("resource://zotero/concurrentCaller.js"); 1445 var caller = new ConcurrentCaller({ 1446 numConcurrent: 4, 1447 stopOnError: true, 1448 logger: msg => Zotero.debug(msg), 1449 onError: e => Zotero.logError(e) 1450 }); 1451 yield caller.start(funcs); 1452 1453 // Convert sets back to arrays 1454 for (let i in results) { 1455 results[i] = Array.from(results[i]); 1456 } 1457 return results; 1458 }), 1459 1460 1461 _throwFriendlyError: function (method, url, status) { 1462 throw new Error( 1463 Zotero.getString('sync.storage.error.webdav.requestError', [status, method]) 1464 + "\n\n" 1465 + Zotero.getString('sync.storage.error.webdav.checkSettingsOrContactAdmin') 1466 + "\n\n" 1467 + Zotero.getString('sync.storage.error.webdav.url', url) 1468 ); 1469 } 1470 }