This commit is contained in:
abaevbog 2026-04-23 10:52:21 +00:00 • committed by GitHub
commit fdaf7d8fb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 226 additions and 6 deletions

View file

@ -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;

View file

@ -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

View file

@ -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);
});
});
});