www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

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 }