diff --git a/chrome/content/zotero/xpcom/server/saveSession.js b/chrome/content/zotero/xpcom/server/saveSession.js index e40bad3931..c3d6e50025 100644 --- a/chrome/content/zotero/xpcom/server/saveSession.js +++ b/chrome/content/zotero/xpcom/server/saveSession.js @@ -49,9 +49,9 @@ Zotero.Server.Connector.SessionManager = { var ttl = this._sessions.size >= 10 ? 60 : 600; var deleteBefore = new Date() - ttl * 1000; - for (let session of this._sessions) { + for (let [_, session] of this._sessions.entries()) { if (session.created < deleteBefore) { - this._session.delete(session.id); + this._sessions.delete(session.id); } } } @@ -63,6 +63,7 @@ Zotero.Server.Connector.SaveSession = class { constructor(id, action, requestData) { this.id = id; this.created = new Date(); + this.cancelled = false; this._action = action; this._requestData = requestData; this._items = {}; @@ -99,6 +100,8 @@ Zotero.Server.Connector.SaveSession = class { } async saveSnapshot(target) { + // This should not be called if session is cancelled but just in case + if (this.cancelled) return; var { library, collection } = Zotero.Server.Connector.resolveTarget(target); var libraryID = library.libraryID; var data = this._requestData.data; @@ -147,7 +150,14 @@ Zotero.Server.Connector.SaveSession = class { } remove() { - delete Zotero.Server.Connector.SessionManager._sessions[this.id]; + Zotero.Server.Connector.SessionManager._sessions.delete(this.id); + } + + async cancel() { + this.cancelled = true; + for (let item of Object.values(this._items)) { + await item.eraseTx(); + } } /** @@ -189,6 +199,8 @@ Zotero.Server.Connector.SaveSession = class { if (Object.values(items).length == 0) { return; } + // This should not be called if session is cancelled but just in case + if (this.cancelled) return; var { library, collection } = Zotero.Server.Connector.resolveTarget(this._currentTargetID); var libraryID = library.libraryID; diff --git a/chrome/content/zotero/xpcom/server/server_connector.js b/chrome/content/zotero/xpcom/server/server_connector.js index 07fe623440..2991c0006b 100644 --- a/chrome/content/zotero/xpcom/server/server_connector.js +++ b/chrome/content/zotero/xpcom/server/server_connector.js @@ -320,8 +320,9 @@ Zotero.Server.Connector.SaveItems.prototype = { ); } catch (e) { + let session = Zotero.Server.Connector.SessionManager.get(data.sessionID); Zotero.debug(e); - return [409, "application/json", JSON.stringify({ error: "SESSION_EXISTS" })]; + return [409, "application/json", JSON.stringify({ error: session?.cancelled ? "SESSION_CANCELLED" : "SESSION_EXISTS" })]; } await session.update(targetID); @@ -499,6 +500,10 @@ Zotero.Server.Connector.SaveAttachment.prototype = { return [200, 'text/plain', 'Library files are not editable.']; } + if (session.cancelled) { + Zotero.debug("Session is cancelled: " + session.sessionID, 1); + return [409, "application/json", JSON.stringify({ error: "SESSION_CANCELLED" })]; + } // Save attachment based on provided parent id from stream let parentItem = session.getItemByConnectorKey(metadata.parentItemID); await Zotero.Attachments.importFromNetworkStream({ @@ -554,6 +559,10 @@ Zotero.Server.Connector.SaveSingleFile.prototype = { Zotero.debug("Can't find session " + data.sessionID, 1); return [400, "application/json", JSON.stringify({ error: "SESSION_NOT_FOUND" })]; } + if (session.cancelled) { + Zotero.debug("Session is cancelled: " + data.sessionID, 1); + return [409, "application/json", JSON.stringify({ error: "SESSION_CANCELLED" })]; + } let { library } = Zotero.Server.Connector.getSaveTarget(); if (!library.filesEditable) { @@ -626,8 +635,9 @@ Zotero.Server.Connector.SaveSnapshot.prototype = { ); } catch (e) { + let session = Zotero.Server.Connector.SessionManager.get(data.sessionID); Zotero.debug(e); - return [409, "application/json", JSON.stringify({ error: "SESSION_EXISTS" })]; + return [409, "application/json", JSON.stringify({ error: session?.cancelled ? "SESSION_CANCELLED" : "SESSION_EXISTS" })]; } await session.update(collection ? collection.treeViewID : library.treeViewID); @@ -747,6 +757,10 @@ Zotero.Server.Connector.UpdateSession.prototype = { Zotero.debug("Can't find session " + data.sessionID, 1); return [400, "application/json", JSON.stringify({ error: "SESSION_NOT_FOUND" })]; } + if (session.cancelled) { + Zotero.debug("Session is cancelled: " + session.sessionID, 1); + return [409, "application/json", JSON.stringify({ error: "SESSION_CANCELLED" })]; + } // Parse treeViewID var [type, id] = [data.target[0], parseInt(data.target.substr(1))]; @@ -771,6 +785,49 @@ Zotero.Server.Connector.UpdateSession.prototype = { } }; +/** + * + * + * Accepts: + * sessionID - A session ID + * + * Returns: + * 200 response if the session is cancelled + * 400 if the session ID is not provided + */ +Zotero.Server.Connector.CancelSession = function() {}; +Zotero.Server.Endpoints["/connector/cancelSession"] = Zotero.Server.Connector.CancelSession; +Zotero.Server.Connector.CancelSession.prototype = { + supportedMethods: ["POST"], + supportedDataTypes: ["application/json"], + permitBookmarklet: true, + + init: async function (requestData) { + var data = requestData.data; + + if (!data.sessionID) { + return [400, "application/json", JSON.stringify({ error: "SESSION_ID_NOT_PROVIDED" })]; + } + + var session = Zotero.Server.Connector.SessionManager.get(data.sessionID); + // If the session with the specified ID does not exist, create it here so + // we can record it as cancelled. + if (!session) { + session = Zotero.Server.Connector.SessionManager.create(data.sessionID); + } + // After a session is cancelled, all items added are removed and + // subsequent child attachments/notes should no longer be added. + // To handle any possible race conditions (e.g. if an item is being added at the same time as + // the session is being cancelled), wait a second and try to clear any remaining items again. + await session.cancel(); + setTimeout(() => { + session.cancel(); + }, 1000); + + return [200, "application/json", JSON.stringify({})]; + } +}; + Zotero.Server.Connector.DelaySync = function () {}; Zotero.Server.Endpoints["/connector/delaySync"] = Zotero.Server.Connector.DelaySync; @@ -961,7 +1018,8 @@ Zotero.Server.Connector.GetSelectedCollection.prototype = { id: library.treeViewID, name: library.name, filesEditable: library.filesEditable, - level: 0 + level: 0, + isUserLibrary: Zotero.Libraries.userLibraryID == library.libraryID, }, ...Zotero.Collections.getByLibrary(library.libraryID, true).map(c => ({ id: c.treeViewID, @@ -1108,6 +1166,7 @@ Zotero.Server.Connector.Ping.prototype = { googleDocsAddNoteEnabled: true, googleDocsAddAnnotationEnabled: true, canUserAddNote: true, + supportsSaveCancelling: true, googleDocsCitationExplorerEnabled: false, translatorsHash, sortedTranslatorHash diff --git a/test/tests/server_connectorTest.js b/test/tests/server_connectorTest.js index 165235b016..4f33c38416 100644 --- a/test/tests/server_connectorTest.js +++ b/test/tests/server_connectorTest.js @@ -1937,4 +1937,153 @@ describe("Connector Server", function () { assert.equal(JSON.parse(req.response).status, 200); }); }); + + describe('/connector/cancelSession', function () { + var pdfArrayBuffer; + var pdfPath = OS.Path.join(getTestDataDirectory().path, 'test.pdf'); + before(async () => { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + pdfArrayBuffer = (await OS.File.read(pdfPath)).buffer; + }); + + it('should delete saved items if called after everything is saved', async function () { + var collection = await createDataObject('collection'); + let sessionID = Zotero.Utilities.randomString(); + let bookItemID = Zotero.Utilities.randomString(); + await select(win, collection); + + let body = { + sessionID, + items: [ + { + id: bookItemID, + itemType: "book", + title: "Book Title", + } + ] + }; + let itemAddPromise = waitForItemEvent('add'); + // Save the item as if by the connector + await Zotero.HTTP.request( + 'POST', + connectorServerPath + "/connector/saveItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(body) + } + ); + let itemIDs = await itemAddPromise; + + // Add the attachment to the item + let attachmentAddPromise = waitForItemEvent('add'); + let attachmentReq = await httpRequest( + 'POST', + connectorServerPath + "/connector/saveAttachment", + { + headers: { + "Content-Type": "application/pdf", + "X-Metadata": JSON.stringify({ + sessionID, + title: "Book Attachment", + parentItemID: bookItemID, + url: `${testServerPath}/attachment1.pdf`, + }) + }, + body: pdfArrayBuffer + } + ); + assert.equal(attachmentReq.status, 201); + let attachmentIds = await attachmentAddPromise; + assert.lengthOf(attachmentIds, 1); + let attachment1 = Zotero.Items.get(attachmentIds[0]); + let bookItem = Zotero.Items.get(itemIDs[0]); + assert.equal(attachment1.parentItemID, bookItem.id); + + // Cancel the session + let cancelReq = await Zotero.HTTP.request( + 'POST', + connectorServerPath + '/connector/cancelSession', + { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionID }) + } + ); + assert.equal(cancelReq.status, 200); + + // The item and attachment should be deleted + let deletedItem = await Zotero.Items.getAsync(attachment1.parentItemID); + let deletedAttachment = await Zotero.Items.getAsync(attachment1.id); + assert.isFalse(deletedItem); + assert.isFalse(deletedAttachment); + }); + + it('should not save attachment items if session is cancelled half-way through', async function () { + var collection = await createDataObject('collection'); + let sessionID = Zotero.Utilities.randomString(); + let bookItemID = Zotero.Utilities.randomString(); + await select(win, collection); + + let body = { + sessionID, + items: [ + { + id: bookItemID, + itemType: "book", + title: "Book Title", + } + ] + }; + + let itemAddPromise = waitForItemEvent('add'); + // Save the item as if by the connector + await Zotero.HTTP.request( + 'POST', + connectorServerPath + "/connector/saveItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(body) + } + ); + let itemIDs = await itemAddPromise; + + // Cancel the session + let cancelReq = await Zotero.HTTP.request( + 'POST', + connectorServerPath + '/connector/cancelSession', + { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionID }) + } + ); + assert.equal(cancelReq.status, 200); + + // Try to add the attachment to the item + let attachmentReq = await getPromiseError(httpRequest( + 'POST', + connectorServerPath + "/connector/saveAttachment", + { + headers: { + "Content-Type": "application/pdf", + "X-Metadata": JSON.stringify({ + sessionID, + title: "Book Attachment", + parentItemID: bookItemID, + url: `${testServerPath}/attachment1.pdf`, + }) + }, + body: pdfArrayBuffer + } + )); + // It should be rejected + assert.equal(attachmentReq.status, 409); + + // The parent item should also be removed + let bookItem = await Zotero.Items.getAsync(itemIDs[0]); + assert.isFalse(bookItem); + }); + }); });