Repository: ether/etherpad (2026-04-04T00:13:07Z)

Issue:
`createDiffHTML` API fails with "Not a changeset: undefined"
**Description**

I'm encountering a 500 Internal Server Error when calling the `createDiffHTML` API endpoint. Regardless of the startRev and endRev specified, the function fails with the error:

```
[ERROR] API - Error: Not a changeset: undefined
  at PadDiff._createDiffAtext (/run/etherpad-lite/src/node/utils/padDiff.ts:182:15)
  at applyToAText (/run/etherpad-lite/src/static/js/Changeset.ts:1061:9)
  at applyToText (/run/etherpad-lite/src/static/js/Changeset.ts:405:20)
  at async <anonymous> (/run/etherpad-lite/src/node/hooks/express/openapi.ts:679:22)
  at async Object.exports.createDiffHTML (/run/etherpad-lite/src/node/db/API.ts:855:16)
  at async OpenAPIBackend.handleRequest (/run/etherpad-lite/node_modules/.pnpm/openapi-backend@5.11.1/node_modules/openapi-backend/src/7)
  at async PadDiff.getHtml (/run/etherpad-lite/src/node/utils/padDiff.ts:194:19)
  at async handler (/run/etherpad-lite/src/node/hooks/express/openapi.ts:633:20)
  at error (/run/etherpad-lite/src/static/js/Changeset.ts:64:13)
  at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
  at unpack (/run/etherpad-lite/src/static/js/Changeset.ts:363:44)
```

Notably, this even happens if the startRev and endRev are the same. 

This suggests that Etherpad is failing to retrieve or process an expected changeset, even though individual revision changesets are retrievable.

**Steps to Reproduce**

1. Create a new pad.
2. Make a few edits to create multiple revisions.
3. Call the API with:
`GET /api/1.3.0/createDiffHTML?padID=<padID>&startRev=1&endRev=1&apikey=<API_KEY>`
4. The API returns 500 Internal Server Error with `"Not a changeset: undefined"`.
5. Additionally, `createDiffHTML(1,2)` and `createDiffHTML(1,10)` fail, while individual revision changesets (`getRevisionChangeset`) return correctly.

**Expected Behavior**

`createDiffHTML(1,1)` should return valid diff HTML (even if it's an empty or minimal diff).
`createDiffHTML(1,2)` should successfully compute a diff between the first two revisions.

**Actual Behavior**

The API call fails with `"Not a changeset: undefined"`.
Etherpad logs show an error originating from `Changeset.ts`, indicating it is unable to apply or retrieve an expected changeset.

**Server (please complete the following information):**
-  
 - Etherpad version: `2.2.7`
 - API Version: `1.3.0`
 - OS: `Ubuntu 22.04.5 LTS`
 - Node.js version (`node --version`): `v20.18.0`
 - npm version (`npm --version`): `10.8.2`
 - Is the server free of plugins: No. 

**Troubleshooting Done**

✅ Verified individual revision changesets (`getRevisionChangeset`) are retrievable.
✅ Tested smaller revision ranges (`createDiffHTML(1,5)`, `createDiffHTML(1,1)`) — still fails.
✅ Tested on brand new pads — still fails.
✅ Checked the Etherpad GitHub issues (no existing issue found).

**Potential Causes**

- Bug in createDiffHTML Logic?
- Changeset Processing Bug: Etherpad might fail to apply early changesets correctly.
- Revision 0 Issue: The initial changeset might not be properly interpreted when computing diffs.



Discussion:
Thanks, @JohnMcLear!



Patch:
diff --git a/src/node/utils/padDiff.ts b/src/node/utils/padDiff.ts
index 53f79fa562d..b6407e65bd4 100644
--- a/src/node/utils/padDiff.ts
+++ b/src/node/utils/padDiff.ts
@@ -20,7 +20,6 @@ class PadDiff {
     private readonly _toRev: string;
     private _html: any;
     public _authors: any[];
-    private self: PadDiff | undefined
   constructor(pad: PadType, fromRev:string, toRev:string) {
     // check parameters
     if (!pad || !pad.id || !pad.atext || !pad.pool) {
@@ -121,12 +120,10 @@ class PadDiff {
     return {changesets, authors};
   }
   _addAuthors(authors: PadAuthor[]){
-      this.self = this;
-
     // add to array if not in the array
     authors.forEach((author) => {
-      if (this.self!._authors.indexOf(author) === -1) {
-        this.self!._authors.push(author);
+      if (this._authors.indexOf(author) === -1) {
+        this._authors.push(author);
       }
     });
   }
@@ -209,7 +206,7 @@ class PadDiff {
       await this.getHtml();
     }
 
-    return this.self!._authors;
+    return this._authors;
   }
 
   _extendChangesetWithAuthor(changeset: any, author: any, apool: any){
@@ -458,12 +455,5 @@ class PadDiff {
 }
 
 
-// this method is 80% like Changeset.inverse. I just changed so instead of reverting,
-// it adds deletions and attribute changes to the atext.
-// @ts-ignore
-PadDiff.prototype._createDeletionChangeset = function (cs, startAText, apool) {
-
-};
-
 // export the constructor
 module.exports = PadDiff;


Test patch:
diff --git a/src/tests/backend/specs/api/createDiffHTML.ts b/src/tests/backend/specs/api/createDiffHTML.ts
new file mode 100644
index 00000000000..e947ac025e6
--- /dev/null
+++ b/src/tests/backend/specs/api/createDiffHTML.ts
@@ -0,0 +1,82 @@
+'use strict';
+
+const assert = require('assert').strict;
+const common = require('../../common');
+
+let agent: any;
+let apiVersion = 1;
+const testPadId = `createDiffHTML_${makeid()}`;
+
+function makeid() {
+  let text = '';
+  const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  for (let i = 0; i < 10; i++) {
+    text += possible.charAt(Math.floor(Math.random() * possible.length));
+  }
+  return text;
+}
+
+const endPoint = (point: string) => `/api/${apiVersion}/${point}`;
+
+describe(__filename, function () {
+  before(async function () {
+    agent = await common.init();
+    const res = await agent.get('/api/')
+        .expect(200)
+        .expect('Content-Type', /json/);
+    apiVersion = res.body.currentVersion;
+    assert(apiVersion);
+
+    // Create pad with multiple revisions for diff testing
+    await agent.get(`${endPoint('createPad')}?padID=${testPadId}`)
+        .set('Authorization', await common.generateJWTToken())
+        .expect(200);
+
+    await agent.post(endPoint('setText'))
+        .set('Authorization', await common.generateJWTToken())
+        .send({padID: testPadId, text: 'first revision'})
+        .expect(200);
+
+    await agent.post(endPoint('setText'))
+        .set('Authorization', await common.generateJWTToken())
+        .send({padID: testPadId, text: 'second revision'})
+        .expect(200);
+  });
+
+  it('createDiffHTML between two different revisions', async function () {
+    const res = await agent.get(
+        `${endPoint('createDiffHTML')}?padID=${testPadId}&startRev=1&endRev=2`)
+        .set('Authorization', await common.generateJWTToken())
+        .expect(200)
+        .expect('Content-Type', /json/);
+    assert.equal(res.body.code, 0);
+    assert(res.body.data.html);
+    assert(Array.isArray(res.body.data.authors));
+  });
+
+  it('createDiffHTML with same startRev and endRev', async function () {
+    const res = await agent.get(
+        `${endPoint('createDiffHTML')}?padID=${testPadId}&startRev=1&endRev=1`)
+        .set('Authorization', await common.generateJWTToken())
+        .expect(200)
+        .expect('Content-Type', /json/);
+    assert.equal(res.body.code, 0);
+    assert(res.body.data.html);
+  });
+
+  it('createDiffHTML from rev 0 to latest', async function () {
+    const res = await agent.get(
+        `${endPoint('createDiffHTML')}?padID=${testPadId}&startRev=0&endRev=2`)
+        .set('Authorization', await common.generateJWTToken())
+        .expect(200)
+        .expect('Content-Type', /json/);
+    assert.equal(res.body.code, 0);
+    assert(res.body.data.html);
+  });
+
+  after(async function () {
+    await agent.get(`${endPoint('deletePad')}?padID=${testPadId}`)
+        .set('Authorization', await common.generateJWTToken())
+        .expect(200);
+  });
+});