zotero/chrome/content/zotero/xpcom/storage/webdav.js

1542 lines
42 KiB
JavaScript

/*
***** BEGIN LICENSE BLOCK *****
Copyright © 2009 Center for History and New Media
George Mason University, Fairfax, Virginia, USA
http://zotero.org
This file is part of Zotero.
Zotero is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Zotero is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
*/
if (!Zotero.Sync.Storage.Mode) {
Zotero.Sync.Storage.Mode = {};
}
Zotero.Sync.Storage.Mode.WebDAV = function (options) {
this.options = options;
this.VerificationError = function (error, uri) {
this.message = `WebDAV verification error (${error})`;
this.error = error;
this.uri = uri;
}
this.VerificationError.prototype = Object.create(Error.prototype);
}
Zotero.Sync.Storage.Mode.WebDAV.prototype = {
mode: "webdav",
name: "WebDAV",
get verified() {
return Zotero.Prefs.get("sync.storage.verified");
},
set verified(val) {
Zotero.Prefs.set("sync.storage.verified", !!val)
},
_initialized: false,
_parentURI: null,
_rootURI: null,
_cachedCredentials: false,
_loginManagerHost: 'chrome://zotero',
_loginManagerRealm: 'Zotero Storage Server',
get defaultError() {
return Zotero.getString('sync.storage.error.webdav.default');
},
get defaultErrorRestart() {
return Zotero.getString('sync.storage.error.webdav.defaultRestart', Zotero.appName);
},
get username() {
return Zotero.Prefs.get('sync.storage.username');
},
get password() {
var username = this.username;
if (!username) {
Zotero.debug('Username not set before getting Zotero.Sync.Storage.WebDAV.password');
return '';
}
Zotero.debug('Getting WebDAV password');
var loginManager = Components.classes["@mozilla.org/login-manager;1"]
.getService(Components.interfaces.nsILoginManager);
var logins = loginManager.findLogins({}, this._loginManagerHost, null, this._loginManagerRealm);
// Find user from returned array of nsILoginInfo objects
for (var i = 0; i < logins.length; i++) {
if (logins[i].username == username) {
return logins[i].password;
}
}
// Pre-4.0.28.5 format, broken for findLogins and removeLogin in Fx41
logins = loginManager.findLogins({}, "chrome://zotero", "", null);
for (var i = 0; i < logins.length; i++) {
if (logins[i].username == username
&& logins[i].formSubmitURL == "Zotero Storage Server") {
return logins[i].password;
}
}
return '';
},
set password(password) {
var username = this.username;
if (!username) {
Zotero.debug('WebDAV username not set before setting password');
return;
}
if (password == this.password) {
Zotero.debug("WebDAV password hasn't changed");
return;
}
_cachedCredentials = false;
var loginManager = Components.classes["@mozilla.org/login-manager;1"]
.getService(Components.interfaces.nsILoginManager);
var logins = loginManager.findLogins({}, this._loginManagerHost, null, this._loginManagerRealm);
for (var i = 0; i < logins.length; i++) {
Zotero.debug('Clearing WebDAV passwords');
if (logins[i].httpRealm == this._loginManagerRealm) {
loginManager.removeLogin(logins[i]);
}
break;
}
// Pre-4.0.28.5 format, broken for findLogins and removeLogin in Fx41
logins = loginManager.findLogins({}, this._loginManagerHost, "", null);
for (var i = 0; i < logins.length; i++) {
Zotero.debug('Clearing old WebDAV passwords');
if (logins[i].formSubmitURL == "Zotero Storage Server") {
try {
loginManager.removeLogin(logins[i]);
}
catch (e) {
Zotero.logError(e);
}
}
break;
}
if (password) {
Zotero.debug('Setting WebDAV password');
var nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
Components.interfaces.nsILoginInfo, "init");
var loginInfo = new nsLoginInfo(this._loginManagerHost, null,
this._loginManagerRealm, username, password, "", "");
loginManager.addLogin(loginInfo);
}
},
get rootURI() {
if (!this._rootURI) {
this._init();
}
return this._rootURI.clone();
},
get parentURI() {
if (!this._parentURI) {
this._init();
}
return this._parentURI.clone();
},
_init: function () {
this._rootURI = false;
this._parentURI = false;
var scheme = Zotero.Prefs.get('sync.storage.scheme');
switch (scheme) {
case 'http':
case 'https':
break;
default:
throw new Error("Invalid WebDAV scheme '" + scheme + "'");
}
var url = Zotero.Prefs.get('sync.storage.url');
if (!url) {
throw new this.VerificationError("NO_URL");
}
url = scheme + '://' + url;
var dir = "zotero";
var username = this.username;
var password = this.password;
if (!username) {
throw new this.VerificationError("NO_USERNAME");
}
if (!password) {
throw new this.VerificationError("NO_PASSWORD");
}
var ios = Components.classes["@mozilla.org/network/io-service;1"].
getService(Components.interfaces.nsIIOService);
var uri = ios.newURI(url, null, null);
uri.username = encodeURIComponent(username);
uri.password = encodeURIComponent(password);
if (!uri.spec.match(/\/$/)) {
uri.spec += "/";
}
this._parentURI = uri;
var uri = uri.clone();
uri.spec += "zotero/";
this._rootURI = uri;
},
cacheCredentials: Zotero.Promise.coroutine(function* () {
if (this._cachedCredentials) {
Zotero.debug("WebDAV credentials are already cached");
return;
}
Zotero.debug("Caching WebDAV credentials");
try {
var req = yield Zotero.HTTP.request("OPTIONS", this.rootURI);
this._checkResponse(req);
Zotero.debug("WebDAV credentials cached");
this._cachedCredentials = true;
}
catch (e) {
if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
let msg = "HTTP " + e.status + " error from WebDAV server "
+ "for OPTIONS request";
Zotero.logError(msg);
throw new Error(this.defaultErrorRestart);
}
throw e;
}
}),
clearCachedCredentials: function() {
this._rootURI = this._parentURI = undefined;
this._cachedCredentials = false;
},
/**
* Begin download process for individual file
*
* @param {Zotero.Sync.Storage.Request} request
* @return {Promise<Zotero.Sync.Storage.Result>}
*/
downloadFile: Zotero.Promise.coroutine(function* (request) {
var item = Zotero.Sync.Storage.Utilities.getItemFromRequest(request);
if (!item) {
throw new Error("Item '" + request.name + "' not found");
}
// Skip download if local file exists and matches mod time
var path = item.getFilePath();
if (!path) {
Zotero.debug(`Cannot download file for attachment ${item.libraryKey} with no path`);
return new Zotero.Sync.Storage.Result;
}
// Retrieve modification time from server
var metadata = yield this._getStorageFileMetadata(item, request);
if (!request.isRunning()) {
Zotero.debug("Download request '" + request.name
+ "' is no longer running after getting mod time");
return new Zotero.Sync.Storage.Result;
}
if (!metadata) {
Zotero.debug("Remote file not found for item " + item.libraryKey);
return new Zotero.Sync.Storage.Result;
}
var fileModTime = yield item.attachmentModificationTime;
if (metadata.mtime == fileModTime) {
Zotero.debug("File mod time matches remote file -- skipping download of "
+ item.libraryKey);
var updateItem = item.attachmentSyncState != 1
item.attachmentSyncedModificationTime = metadata.mtime;
item.attachmentSyncState = "in_sync";
yield item.saveTx({ skipAll: true });
// DEBUG: Necessary?
if (updateItem) {
yield item.updateSynced(false);
}
return new Zotero.Sync.Storage.Result({
localChanges: true, // ?
});
}
var uri = this._getItemURI(item);
var destPath = OS.Path.join(Zotero.getTempDirectory().path, item.key + '.tmp');
yield Zotero.File.removeIfExists(destPath);
var deferred = Zotero.Promise.defer();
var requestData = {
item,
mtime: metadata.mtime,
md5: metadata.md5,
compressed: true
};
var listener = new Zotero.Sync.Storage.StreamListener(
{
onStart: function (req) {
if (request.isFinished()) {
Zotero.debug("Download request " + request.name
+ " stopped before download started -- closing channel");
req.cancel(0x804b0002); // NS_BINDING_ABORTED
deferred.resolve(new Zotero.Sync.Storage.Result);
}
},
onProgress: function (a, b, c) {
request.onProgress(a, b, c)
},
onStop: Zotero.Promise.coroutine(function* (req, status, res) {
request.setChannel(false);
if (status == 404) {
let msg = "Remote ZIP file not found for item " + item.libraryKey;
Zotero.debug(msg, 2);
Components.utils.reportError(msg);
// Delete the orphaned prop file
try {
yield this._deleteStorageFiles([item.key + ".prop"]);
}
catch (e) {
Zotero.logError(e);
}
deferred.resolve(new Zotero.Sync.Storage.Result);
return;
}
else if (status != 200) {
try {
this._throwFriendlyError("GET", dispURL, status);
}
catch (e) {
deferred.reject(e);
}
return;
}
// Don't try to process if the request has been cancelled
if (request.isFinished()) {
Zotero.debug("Download request " + request.name
+ " is no longer running after file download");
deferred.resolve(new Zotero.Sync.Storage.Result);
return;
}
Zotero.debug("Finished download of " + destPath);
try {
deferred.resolve(
Zotero.Sync.Storage.Local.processDownload(requestData)
);
}
catch (e) {
deferred.reject(e);
}
}.bind(this)),
onCancel: function (req, status) {
Zotero.debug("Request cancelled");
if (deferred.promise.isPending()) {
deferred.resolve(new Zotero.Sync.Storage.Result);
}
}
}
);
// Don't display password in console
var dispURL = Zotero.HTTP.getDisplayURI(uri).spec;
Zotero.debug('Saving ' + dispURL);
const nsIWBP = Components.interfaces.nsIWebBrowserPersist;
var wbp = Components.classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
.createInstance(nsIWBP);
wbp.persistFlags = nsIWBP.PERSIST_FLAGS_BYPASS_CACHE;
wbp.progressListener = listener;
Zotero.Utilities.Internal.saveURI(wbp, uri, destPath);
return deferred.promise;
}),
uploadFile: Zotero.Promise.coroutine(function* (request) {
var item = Zotero.Sync.Storage.Utilities.getItemFromRequest(request);
var params = {
mtime: yield item.attachmentModificationTime,
md5: yield item.attachmentHash
};
var metadata = yield this._getStorageFileMetadata(item, request);
if (!request.isRunning()) {
Zotero.debug("Upload request '" + request.name
+ "' is no longer running after getting metadata");
return new Zotero.Sync.Storage.Result;
}
// Check if file already exists on WebDAV server
if (item.attachmentSyncState
!= Zotero.Sync.Storage.Local.SYNC_STATE_FORCE_UPLOAD) {
if (metadata.mtime) {
// Local file time
let fmtime = yield item.attachmentModificationTime;
// Remote prop time
let mtime = metadata.mtime;
var changed = Zotero.Sync.Storage.Local.checkFileModTime(item, fmtime, mtime);
if (!changed) {
// Remote hash
let hash = metadata.md5;
if (hash) {
// Local file hash
let fhash = yield item.attachmentHash;
if (fhash != hash) {
changed = true;
}
}
// If WebDAV server already has file, update synced properties
if (!changed) {
item.attachmentSyncedModificationTime = fmtime;
if (hash) {
item.attachmentSyncedHash = hash;
}
item.attachmentSyncState = "in_sync";
yield item.saveTx({ skipAll: true });
// skipAll doesn't mark as unsynced, so do that separately
yield item.updateSynced(false);
return new Zotero.Sync.Storage.Result;
}
}
// Check for conflict between synced values and values on WebDAV server. This
// should almost never happen, but it's possible if a client uploaded to WebDAV
// but failed before updating the API (or the local properties if this computer),
// or if the file was changed identically on two computers at the same time, such
// that the post-upload API update on computer B happened after the pre-upload API
// check on computer A. (In the case of a failure, there's no guarantee that the
// API would ever be updated with the correct values, so we can't just wait for
// the API to change.) If a conflict is found, we flag the item as in conflict
// and require another file sync, which will trigger conflict resolution.
let smtime = item.attachmentSyncedModificationTime;
if (smtime != mtime) {
let shash = item.attachmentSyncedHash;
if (shash && metadata.md5 && shash == metadata.md5) {
Zotero.debug("Last synced mod time for item " + item.libraryKey
+ " doesn't match time on storage server but hash does -- ignoring");
return new Zotero.Sync.Storage.Result;
}
Zotero.logError("Conflict -- last synced file mod time for item "
+ item.libraryKey + " does not match time on storage server"
+ " (" + smtime + " != " + mtime + ")");
// Conflict resolution uses the synced mtime as the remote value, so set
// that to the WebDAV value, since that's the one in conflict.
item.attachmentSyncedModificationTime = mtime;
item.attachmentSyncState = "in_conflict";
yield item.saveTx({ skipAll: true });
return new Zotero.Sync.Storage.Result({
fileSyncRequired: true
});
}
}
else {
Zotero.debug("Remote file not found for item " + item.id);
}
}
var created = yield Zotero.Sync.Storage.Utilities.createUploadFile(request);
if (!created) {
return new Zotero.Sync.Storage.Result;
}
/*
updateSizeMultiplier(
(100 - Zotero.Sync.Storage.compressionTracker.ratio) / 100
);
*/
// Delete .prop file before uploading new .zip
if (metadata) {
var propURI = this._getItemPropertyURI(item);
try {
yield Zotero.HTTP.request(
"DELETE",
propURI,
{
successCodes: [200, 204, 404],
requestObserver: xmlhttp => request.setChannel(xmlhttp.channel),
debug: true
}
);
}
catch (e) {
if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
this._throwFriendlyError("DELETE", Zotero.HTTP.getDisplayURI(propURI).spec, e.status);
}
throw e;
}
}
var file = Zotero.getTempDirectory();
file.append(item.key + '.zip');
Components.utils.importGlobalProperties(["File"]);
file = File.createFromFileName ? File.createFromFileName(file.path) : new File(file);
// File.createFromFileName() returns a Promise in Fx54+
if (file.then) {
file = yield file;
}
var uri = this._getItemURI(item);
try {
var req = yield Zotero.HTTP.request(
"PUT",
uri,
{
headers: {
"Content-Type": "application/zip"
},
body: file,
requestObserver: function (req) {
request.setChannel(req.channel);
req.upload.addEventListener("progress", function (event) {
if (event.lengthComputable) {
request.onProgress(event.loaded, event.total);
}
});
},
debug: true
}
);
}
catch (e) {
if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
if (e.status == 507) {
throw new Error(
Zotero.getString('sync.storage.error.webdav.insufficientSpace')
);
}
this._throwFriendlyError("PUT", Zotero.HTTP.getDisplayURI(uri).spec, e.status);
}
throw e;
// TODO: Detect cancel?
//onUploadCancel(httpRequest, status, data)
//deferred.resolve(false);
}
request.setChannel(false);
return this._onUploadComplete(req, request, item, params);
}),
/**
* @return {Promise}
* @throws {Zotero.Sync.Storage.Mode.WebDAV.VerificationError|Error}
*/
checkServer: Zotero.Promise.coroutine(function* (options = {}) {
// Clear URIs
this._init();
var parentURI = this.parentURI;
var uri = this.rootURI;
var xmlstr = "<propfind xmlns='DAV:'><prop>"
// IIS 5.1 requires at least one property in PROPFIND
+ "<getcontentlength/>"
+ "</prop></propfind>";
var channel;
var requestObserver = function (req) {
if (options.onRequest) {
options.onRequest(req);
}
}
// Test whether URL is WebDAV-enabled
try {
var req = yield Zotero.HTTP.request(
"OPTIONS",
uri,
{
successCodes: [200, 404],
requestObserver: function (req) {
if (req.channel) {
channel = req.channel;
}
if (options.onRequest) {
options.onRequest(req);
}
},
debug: true
}
);
}
catch (e) {
if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
this._checkResponse(e.xmlhttp, e.channel);
}
throw e;
}
Zotero.debug(req.getAllResponseHeaders());
var dav = req.getResponseHeader("DAV");
if (dav == null) {
throw new this.VerificationError("NOT_DAV", uri);
}
var headers = { Depth: 0 };
// Get the Authorization header used in case we need to do a request
// on the parent below
if (channel) {
var channelAuthorization = Zotero.HTTP.getChannelAuthorization(channel);
Zotero.debug(channelAuthorization);
channel = null;
}
// Test whether Zotero directory exists
req = yield Zotero.HTTP.request("PROPFIND", uri, {
body: xmlstr,
headers,
successCodes: [207, 404],
requestObserver,
debug: true
});
if (req.status == 207) {
// Test if missing files return 404s
let missingFileURI = uri.clone();
missingFileURI.spec += "nonexistent.prop";
try {
req = yield Zotero.HTTP.request(
"GET",
missingFileURI,
{
successCodes: [404],
requestObserver,
debug: true
}
)
}
catch (e) {
if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
if (e.status >= 200 && e.status < 300) {
throw this.VerificationError("NONEXISTENT_FILE_NOT_MISSING", uri);
}
}
throw e;
}
// Test if Zotero directory is writable
let testFileURI = uri.clone();
testFileURI.spec += "zotero-test-file.prop";
req = yield Zotero.HTTP.request("PUT", testFileURI, {
body: " ",
successCodes: [200, 201, 204],
requestObserver,
debug: true
});
req = yield Zotero.HTTP.request(
"GET",
testFileURI,
{
successCodes: [200, 404],
requestObserver,
debug: true
}
);
if (req.status == 200) {
// Delete test file
yield Zotero.HTTP.request(
"DELETE",
testFileURI,
{
successCodes: [200, 204],
requestObserver,
debug: true
}
);
}
// This can happen with cloud storage services backed by S3 or other eventually
// consistent data stores.
//
// This can also be from IIS 6+, which is configured not to serve .prop files.
// http://support.microsoft.com/kb/326965
else if (req.status == 404) {
throw new this.VerificationError("FILE_MISSING_AFTER_UPLOAD", uri);
}
}
else if (req.status == 404) {
// Include Authorization header from /zotero request,
// since Firefox probably won't apply it to the parent request
if (channelAuthorization) {
headers.Authorization = channelAuthorization;
}
// Zotero directory wasn't found, so see if at least
// the parent directory exists
req = yield Zotero.HTTP.request("PROPFIND", parentURI, {
headers,
body: xmlstr,
requestObserver,
successCodes: [207, 404]
});
if (req.status == 207) {
throw new this.VerificationError("ZOTERO_DIR_NOT_FOUND", uri);
}
else if (req.status == 404) {
throw new this.VerificationError("PARENT_DIR_NOT_FOUND", uri);
}
}
this.verified = true;
Zotero.debug(this.name + " file sync is successfully set up");
}),
/**
* Handles the result of WebDAV verification, displaying an alert if necessary.
*
* @return bool True if the verification eventually succeeded, false otherwise
*/
handleVerificationError: Zotero.Promise.coroutine(function* (err, window, skipSuccessMessage) {
var promptService =
Components.classes["@mozilla.org/embedcomp/prompt-service;1"].
createInstance(Components.interfaces.nsIPromptService);
var uri = err.uri;
if (uri) {
var spec = uri.scheme + '://' + uri.hostPort + uri.path;
}
var errorTitle, errorMsg;
if (err instanceof Zotero.HTTP.UnexpectedStatusException) {
switch (err.status) {
case 0:
errorMsg = Zotero.getString('sync.storage.error.serverCouldNotBeReached', err.channel.URI.host);
break;
case 401:
errorTitle = Zotero.getString('general.permissionDenied');
errorMsg = Zotero.getString('sync.storage.error.webdav.invalidLogin') + "\n\n"
+ Zotero.getString('sync.storage.error.checkFileSyncSettings');
break;
case 403:
errorTitle = Zotero.getString('general.permissionDenied');
errorMsg = Zotero.getString('sync.storage.error.webdav.permissionDenied', err.channel.URI.path)
+ "\n\n" + Zotero.getString('sync.storage.error.checkFileSyncSettings');
break;
case 500:
errorTitle = Zotero.getString('sync.storage.error.webdav.serverConfig.title');
errorMsg = Zotero.getString('sync.storage.error.webdav.serverConfig')
+ "\n\n" + Zotero.getString('sync.storage.error.checkFileSyncSettings');
break;
default:
errorMsg = Zotero.getString('general.unknownErrorOccurred') + "\n\n"
+ Zotero.getString('sync.storage.error.checkFileSyncSettings') + "\n\n"
+ "HTTP " + err.status;
break;
}
}
else if (err instanceof this.VerificationError) {
switch (err.error) {
case "NO_URL":
errorMsg = Zotero.getString('sync.storage.error.webdav.enterURL');
break;
case "NO_USERNAME":
errorMsg = Zotero.getString('sync.error.usernameNotSet');
break;
case "NO_PASSWORD":
errorMsg = Zotero.getString('sync.error.enterPassword');
break;
case "NOT_DAV":
errorMsg = Zotero.getString('sync.storage.error.webdav.invalidURL', spec);
break;
case "PARENT_DIR_NOT_FOUND":
errorTitle = Zotero.getString('sync.storage.error.directoryNotFound');
var parentSpec = spec.replace(/\/zotero\/$/, "");
errorMsg = Zotero.getString('sync.storage.error.doesNotExist', parentSpec);
break;
case "ZOTERO_DIR_NOT_FOUND":
var create = promptService.confirmEx(
window,
Zotero.getString('sync.storage.error.directoryNotFound'),
Zotero.getString('sync.storage.error.doesNotExist', spec) + "\n\n"
+ Zotero.getString('sync.storage.error.createNow'),
promptService.BUTTON_POS_0
* promptService.BUTTON_TITLE_IS_STRING
+ promptService.BUTTON_POS_1
* promptService.BUTTON_TITLE_CANCEL,
Zotero.getString('general.create'),
null, null, null, {}
);
if (create != 0) {
return;
}
try {
yield this._createServerDirectory();
}
catch (e) {
if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
if (e.status == 403) {
errorTitle = Zotero.getString('general.permissionDenied');
let rootURI = this.rootURI;
let rootSpec = rootURI.scheme + '://' + rootURI.hostPort + rootURI.path
errorMsg = Zotero.getString('sync.storage.error.permissionDeniedAtAddress')
+ "\n\n" + rootSpec + "\n\n"
+ Zotero.getString('sync.storage.error.checkFileSyncSettings');
break;
}
}
errorMsg = e;
break;
}
try {
yield this.checkServer();
return true;
}
catch (e) {
return this.handleVerificationError(e, window, skipSuccessMessage);
}
break;
case "FILE_MISSING_AFTER_UPLOAD":
errorTitle = Zotero.getString("general.warning");
errorMsg = Zotero.getString('sync.storage.error.webdav.fileMissingAfterUpload');
Zotero.Prefs.set("sync.storage.verified", true);
break;
case "NONEXISTENT_FILE_NOT_MISSING":
var errorTitle = Zotero.getString('sync.storage.error.webdav.serverConfig.title');
errorMsg = Zotero.getString('sync.storage.error.webdav.nonexistentFileNotMissing');
break;
default:
errorMsg = Zotero.getString('general.unknownErrorOccurred') + "\n\n"
Zotero.getString('sync.storage.error.checkFileSyncSettings');
break;
}
}
// TEMP
if (!errorMsg) {
errorMsg = err;
}
Zotero.logError(errorMsg);
if (!skipSuccessMessage) {
if (!errorTitle) {
var errorTitle = Zotero.getString("general.error");
}
promptService.alert(window, errorTitle, errorMsg);
}
return false;
}),
/**
* Remove files on storage server that were deleted locally
*
* @param {Integer} libraryID
*/
purgeDeletedStorageFiles: Zotero.Promise.coroutine(function* (libraryID) {
Zotero.debug("Purging deleted storage files");
var files = yield Zotero.Sync.Storage.Local.getDeletedFiles(libraryID);
if (!files.length) {
Zotero.debug("No files to delete remotely");
return false;
}
// Add .zip extension
var files = files.map(file => file + ".zip");
var results = yield this._deleteStorageFiles(files)
// Remove deleted and nonexistent files from storage delete log
var toPurge = Zotero.Utilities.arrayUnique(
results.deleted.concat(results.missing)
// Strip file extension so we just have keys
.map(val => val.replace(/\.(prop|zip)$/, ""))
);
if (toPurge.length > 0) {
yield Zotero.Utilities.Internal.forEachChunkAsync(
toPurge,
Zotero.DB.MAX_BOUND_PARAMETERS - 1,
function (chunk) {
return Zotero.DB.executeTransaction(function* () {
var sql = "DELETE FROM storageDeleteLog WHERE libraryID=? AND key IN ("
+ chunk.map(() => '?').join() + ")";
return Zotero.DB.queryAsync(sql, [libraryID].concat(chunk));
});
}
);
}
Zotero.debug(results);
return results;
}),
/**
* Delete orphaned storage files older than a week before last sync time
*/
purgeOrphanedStorageFiles: Zotero.Promise.coroutine(function* () {
const libraryID = Zotero.Libraries.userLibraryID;
const daysBeforeSyncTime = 7;
// If recently purged, skip
var lastPurge = Zotero.Prefs.get('lastWebDAVOrphanPurge');
if (lastPurge) {
try {
lastPurge = new Date(lastPurge * 1000);
let purgeAfter = lastPurge + (daysBeforeSyncTime * 24 * 60 * 60 * 1000);
if (new Date() > purgeAfter) {
return false;
}
}
catch (e) {
Zotero.Prefs.clear('lastWebDAVOrphanPurge');
}
}
Zotero.debug("Purging orphaned storage files");
var uri = this.rootURI;
var path = uri.path;
var xmlstr = "<propfind xmlns='DAV:'><prop>"
+ "<getlastmodified/>"
+ "</prop></propfind>";
var lastSyncDate = Zotero.Libraries.userLibrary.lastSync;
if (!lastSyncDate) {
Zotero.debug(`No last sync date for library ${libraryID} -- not purging orphaned files`);
return false;
}
var req = yield Zotero.HTTP.request(
"PROPFIND",
uri,
{
body: xmlstr,
headers: {
Depth: 1
},
successCodes: [207],
debug: true
}
);
var responseNode = req.responseXML.documentElement;
responseNode.xpath = function (path) {
return Zotero.Utilities.xpath(this, path, { D: 'DAV:' });
};
var deleteFiles = [];
var trailingSlash = !!path.match(/\/$/);
for (let response of responseNode.xpath("D:response")) {
var href = Zotero.Utilities.xpathText(
response, "D:href", { D: 'DAV:' }
) || "";
Zotero.debug("Checking response entry " + href);
// Strip trailing slash if there isn't one on the root path
if (!trailingSlash) {
href = href.replace(/\/$/, "");
}
// Absolute
if (href.match(/^https?:\/\//)) {
var ios = Components.classes["@mozilla.org/network/io-service;1"].
getService(Components.interfaces.nsIIOService);
var href = ios.newURI(href, null, null);
href = href.path;
}
// Skip root URI
if (href == path
// Some Apache servers respond with a "/zotero" href
// even for a "/zotero/" request
|| (trailingSlash && href + '/' == path)
// Try URL-encoded as well, as above
|| decodeURIComponent(href) == path) {
continue;
}
if (href.indexOf(path) == -1
// Try URL-encoded as well, in case there's a '~' or similar
// character in the URL and the server (e.g., Sakai) is
// encoding the value
&& decodeURIComponent(href).indexOf(path) == -1) {
throw new Error(
"DAV:href '" + href + "' does not begin with path '"
+ path + "' in " + funcName
);
}
var matches = href.match(/[^\/]+$/);
if (!matches) {
throw new Error(
"Unexpected href '" + href + "' in " + funcName
);
}
var file = matches[0];
if (file.indexOf('.') == 0) {
Zotero.debug("Skipping hidden file " + file);
continue;
}
var isLastSyncFile = file !== 'lastsync.txt' || file != 'lastsync';
if (!file.match(/\.zip$/) && !file.match(/\.prop$/) && !isLastSyncFile) {
Zotero.debug("Skipping file " + file);
continue;
}
if (!isLastSyncFile) {
var key = file.replace(/\.(zip|prop)$/, '');
var item = yield Zotero.Items.getByLibraryAndKeyAsync(libraryID, key);
if (item) {
Zotero.debug("Skipping existing file " + file);
continue;
}
}
Zotero.debug("Checking orphaned file " + file);
// TODO: Parse HTTP date properly
Zotero.debug(response.innerHTML);
var lastModified = Zotero.Utilities.xpathText(
response, ".//D:getlastmodified", { D: 'DAV:' }
);
lastModified = Zotero.Date.strToISO(lastModified);
lastModified = Zotero.Date.sqlToDate(lastModified, true);
// Delete files older than a day before last sync time
var days = (lastSyncDate - lastModified) / 1000 / 60 / 60 / 24;
if (days > daysBeforeSyncTime) {
deleteFiles.push(file);
}
}
var results = yield this._deleteStorageFiles(deleteFiles);
Zotero.Prefs.set("lastWebDAVOrphanPurge", Math.round(new Date().getTime() / 1000));
Zotero.debug(results);
}),
//
// Private methods
//
/**
* Get mod time and hash of file on storage server
*
* @param {Zotero.Item} item
* @param {Zotero.Sync.Storage.Request} request
* @return {Object} - Object with 'mtime' and 'md5'
*/
_getStorageFileMetadata: Zotero.Promise.coroutine(function* (item, request) {
var uri = this._getItemPropertyURI(item);
try {
var req = yield Zotero.HTTP.request(
"GET",
uri,
{
successCodes: [200, 300, 404],
requestObserver: xmlhttp => request.setChannel(xmlhttp.channel),
dontCache: true,
debug: true
}
);
}
catch (e) {
if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
this._throwFriendlyError("GET", Zotero.HTTP.getDisplayURI(uri).spec, e.status);
}
throw e;
}
this._checkResponse(req);
// mod_speling can return 300s for 404s with base name matches
if (req.status == 404 || req.status == 300) {
return false;
}
// No metadata set
if (!req.responseText) {
return false;
}
var seconds = false;
var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"]
.createInstance(Components.interfaces.nsIDOMParser);
try {
var xml = parser.parseFromString(req.responseText, "text/xml");
}
catch (e) {
Zotero.logError(e);
}
var mtime = false;
var md5 = false;
if (xml) {
try {
var mtime = xml.getElementsByTagName('mtime')[0].textContent;
}
catch (e) {}
try {
var md5 = xml.getElementsByTagName('hash')[0].textContent;
}
catch (e) {}
}
// TEMP: Accept old non-XML prop files with just mtimes in seconds
if (!mtime) {
mtime = req.responseText;
seconds = true;
}
var invalid = false;
// Unix timestamps need to be converted to ms-based timestamps
if (seconds) {
if (mtime.match(/^[0-9]{1,10}$/)) {
Zotero.debug("Converting Unix timestamp '" + mtime + "' to milliseconds");
mtime = mtime * 1000;
}
else {
invalid = true;
}
}
else if (!mtime.match(/^[0-9]{1,13}$/)) {
invalid = true;
}
// Delete invalid .prop files
if (invalid) {
let msg = "Invalid mod date '" + Zotero.Utilities.ellipsize(mtime, 20)
+ "' for item " + item.libraryKey;
Zotero.logError(msg);
yield this._deleteStorageFiles([item.key + ".prop"]).catch(function (e) {
Zotero.logError(e);
});
throw new Error(Zotero.Sync.Storage.Mode.WebDAV.defaultError);
}
return {
mtime: parseInt(mtime),
md5
};
}),
/**
* Set mod time and hash of file on storage server
*
* @param {Zotero.Item} item
*/
_setStorageFileMetadata: Zotero.Promise.coroutine(function* (item) {
var uri = this._getItemPropertyURI(item);
var mtime = yield item.attachmentModificationTime;
var md5 = yield item.attachmentHash;
var xmlstr = '<properties version="1">'
+ '<mtime>' + mtime + '</mtime>'
+ '<hash>' + md5 + '</hash>'
+ '</properties>';
try {
yield Zotero.HTTP.request(
"PUT",
uri,
{
headers: {
"Content-Type": "text/xml"
},
body: xmlstr,
successCodes: [200, 201, 204],
debug: true
}
)
}
catch (e) {
if (e instanceof Zotero.HTTP.UnexpectedStatusException) {
this._throwFriendlyError("PUT", Zotero.HTTP.getDisplayURI(uri).spec, e.status);
}
throw e;
}
}),
_onUploadComplete: Zotero.Promise.coroutine(function* (req, request, item, params) {
Zotero.debug("Upload of attachment " + item.key + " finished with status code " + req.status);
Zotero.debug(req.responseText);
// Update .prop file on WebDAV server
yield this._setStorageFileMetadata(item);
item.attachmentSyncedModificationTime = params.mtime;
item.attachmentSyncedHash = params.md5;
item.attachmentSyncState = "in_sync";
yield item.saveTx({ skipAll: true });
// skipAll doesn't mark as unsynced, so do that separately
yield item.updateSynced(false);
try {
yield OS.File.remove(
OS.Path.join(Zotero.getTempDirectory().path, item.key + '.zip')
);
}
catch (e) {
Zotero.logError(e);
}
return new Zotero.Sync.Storage.Result({
localChanges: true,
remoteChanges: true,
syncRequired: true
});
}),
_onUploadCancel: function (httpRequest, status, data) {
var request = data.request;
var item = data.item;
Zotero.debug("Upload of attachment " + item.key + " cancelled with status code " + status);
try {
var file = Zotero.getTempDirectory();
file.append(item.key + '.zip');
file.remove(false);
}
catch (e) {
Components.utils.reportError(e);
}
},
/**
* Create a Zotero directory on the storage server
*/
_createServerDirectory: function () {
return Zotero.HTTP.request(
"MKCOL",
this.rootURI,
{
successCodes: [201]
}
);
},
/**
* Get the storage URI for an item
*
* @inner
* @param {Zotero.Item}
* @return {nsIURI} URI of file on storage server
*/
_getItemURI: function (item) {
var uri = this.rootURI;
uri.spec = uri.spec + item.key + '.zip';
return uri;
},
/**
* Get the storage property file URI for an item
*
* @inner
* @param {Zotero.Item}
* @return {nsIURI} URI of property file on storage server
*/
_getItemPropertyURI: function (item) {
var uri = this.rootURI;
uri.spec = uri.spec + item.key + '.prop';
return uri;
},
/**
* Get the storage property file URI corresponding to a given item storage URI
*
* @param {nsIURI} Item storage URI
* @return {nsIURI|FALSE} Property file URI, or FALSE if not an item storage URI
*/
_getPropertyURIFromItemURI: function (uri) {
if (!uri.spec.match(/\.zip$/)) {
return false;
}
var propURI = uri.clone();
propURI.QueryInterface(Components.interfaces.nsIURL);
propURI.fileName = uri.fileName.replace(/\.zip$/, '.prop');
propURI.QueryInterface(Components.interfaces.nsIURI);
return propURI;
},
/**
* @inner
* @param {String[]} files - Filenames of files to delete
* @return {Object} - Object with properties 'deleted', 'missing', and 'error', each
* each containing filenames
*/
_deleteStorageFiles: Zotero.Promise.coroutine(function* (files) {
var results = {
deleted: [],
missing: [],
error: []
};
if (files.length == 0) {
return results;
}
// Delete .prop files first
files.sort(function (a, b) {
if (a.endsWith('.zip') && b.endsWith('.prop')) return 1;
if (b.endsWith('.zip') && a.endsWith('.prop')) return 1;
return 0;
});
let deleteURI = this.rootURI.clone();
// This should never happen, but let's be safe
if (!deleteURI.spec.match(/\/$/)) {
throw new Error("Root URI does not end in slash");
}
var funcs = [];
for (let i = 0 ; i < files.length; i++) {
let fileName = files[i];
funcs.push(Zotero.Promise.coroutine(function* () {
var deleteURI = this.rootURI.clone();
deleteURI.QueryInterface(Components.interfaces.nsIURL);
deleteURI.fileName = fileName;
deleteURI.QueryInterface(Components.interfaces.nsIURI);
try {
var req = yield Zotero.HTTP.request(
"DELETE",
deleteURI,
{
successCodes: [200, 204, 404]
}
);
}
catch (e) {
results.error.push(fileName);
throw e;
}
switch (req.status) {
case 204:
// IIS 5.1 and Sakai return 200
case 200:
results.deleted.push(fileName);
break;
case 404:
results.missing.push(fileName);
break;
}
// If an item file URI, get the property URI
var deletePropURI = this._getPropertyURIFromItemURI(deleteURI);
// If we already deleted the prop file, skip it
if (!deletePropURI || results.deleted.indexOf(deletePropURI.fileName) != -1) {
return;
}
fileName = deletePropURI.fileName;
// Delete property file
var req = yield Zotero.HTTP.request(
"DELETE",
deletePropURI,
{
successCodes: [200, 204, 404]
}
);
switch (req.status) {
case 204:
// IIS 5.1 and Sakai return 200
case 200:
results.deleted.push(fileName);
break;
case 404:
results.missing.push(fileName);
break;
}
}.bind(this)));
}
Components.utils.import("resource://zotero/concurrentCaller.js");
var caller = new ConcurrentCaller({
numConcurrent: 4,
stopOnError: true,
logger: msg => Zotero.debug(msg),
onError: e => Zotero.logError(e)
});
yield caller.start(funcs);
return results;
}),
/**
* Checks for an invalid SSL certificate and throws a nice error
*/
_checkResponse: function (req, channel) {
if (req.status != 0) return;
// Check if the error we encountered is really an SSL error
// Logic borrowed from https://developer.mozilla.org/en-US/docs/How_to_check_the_security_state_of_an_XMLHTTPRequest_over_SSL
// http://mxr.mozilla.org/mozilla-central/source/security/nss/lib/ssl/sslerr.h
// http://mxr.mozilla.org/mozilla-central/source/security/nss/lib/util/secerr.h
var secErrLimit = Ci.nsINSSErrorsService.NSS_SEC_ERROR_LIMIT - Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE;
var secErr = Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE) - (channel.status & 0xffff);
var sslErrLimit = Ci.nsINSSErrorsService.NSS_SSL_ERROR_LIMIT - Ci.nsINSSErrorsService.NSS_SSL_ERROR_BASE;
var sslErr = Math.abs(Ci.nsINSSErrorsService.NSS_SSL_ERROR_BASE) - (channel.status & 0xffff);
if( (secErr < 0 || secErr > secErrLimit) && (sslErr < 0 || sslErr > sslErrLimit) ) {
return;
}
var secInfo = channel.securityInfo;
if (secInfo instanceof Ci.nsITransportSecurityInfo) {
secInfo.QueryInterface(Ci.nsITransportSecurityInfo);
if ((secInfo.securityState & Ci.nsIWebProgressListener.STATE_IS_INSECURE) == Ci.nsIWebProgressListener.STATE_IS_INSECURE) {
var host = 'host';
try {
host = channel.URI.host;
}
catch (e) {
Zotero.debug(e);
}
var msg = Zotero.getString('sync.storage.error.webdav.sslCertificateError', host);
// In Standalone, provide cert_override.txt instructions and a
// button to open the Zotero profile directory
if (Zotero.isStandalone) {
msg += "\n\n" + Zotero.getString('sync.storage.error.webdav.seeCertOverrideDocumentation');
var buttonText = Zotero.getString('general.openDocumentation');
var func = function () {
var zp = Zotero.getActiveZoteroPane();
zp.loadURI("https://www.zotero.org/support/kb/cert_override", { shiftKey: true });
};
}
// In Firefox display a button to load the WebDAV URL
else {
msg += "\n\n" + Zotero.getString('sync.storage.error.webdav.loadURLForMoreInfo');
var buttonText = Zotero.getString('sync.storage.error.webdav.loadURL');
var func = function () {
var zp = Zotero.getActiveZoteroPane();
zp.loadURI(channel.URI.spec, { shiftKey: true });
};
}
var e = new Zotero.Error(
msg,
0,
{
dialogButtonText: buttonText,
dialogButtonCallback: func
}
);
throw e;
}
else if ((secInfo.securityState & Ci.nsIWebProgressListener.STATE_IS_BROKEN) == Ci.nsIWebProgressListener.STATE_IS_BROKEN) {
var msg = Zotero.getString('sync.storage.error.webdav.sslConnectionError', host) +
Zotero.getString('sync.storage.error.webdav.loadURLForMoreInfo');
var e = new Zotero.Error(
msg,
0,
{
dialogButtonText: Zotero.getString('sync.storage.error.webdav.loadURL'),
dialogButtonCallback: function () {
var zp = Zotero.getActiveZoteroPane();
zp.loadURI(channel.URI.spec, { shiftKey: true });
}
}
);
throw e;
}
}
},
_throwFriendlyError: function (method, url, status) {
throw new Error(
Zotero.getString('sync.storage.error.webdav.requestError', [status, method])
+ "\n\n"
+ Zotero.getString('sync.storage.error.webdav.checkSettingsOrContactAdmin')
+ "\n\n"
+ Zotero.getString('sync.storage.error.webdav.url', url)
);
}
}