mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-22 09:36:53 +00:00
Fixes #5071. `/p/:pad/:rev/export/etherpad` has always ignored the rev parameter and returned the full pad history, unlike the txt/html export endpoints which use the same route but do respect rev. Users wanting to back up or inspect a snapshot of a pad at a specific rev got every later revision in the payload instead — both wasteful and a surprise when the downloaded .etherpad blob contained content that had supposedly been reverted. Change: - `exportEtherpad.getPadRaw(padId, readOnlyId, revNum?)` now takes an optional revNum. When supplied, it clamps to `min(revNum, pad.head)`, iterates only revs 0..effectiveHead, and ships a shallow-cloned pad object whose `head` and `atext` reflect the requested snapshot. The original live Pad is still passed to the `exportEtherpad` hook so plugin callbacks see the real document. - `ExportHandler` passes `req.params.rev` through on the `etherpad` type, matching the existing behavior of `txt` and `html`. - Chat history is intentionally left full (it is not rev-anchored). Adds three backend regression tests under `ExportEtherpad.ts`: - default (no revNum) still exports the full history - explicit revNum limits exported revs and rewrites the serialized head so re-import reconstructs the pad at that rev - revNum above head is treated as full history, preventing accidental truncation of short pads Out of scope: `getHTML(padID, rev)` on the API side is already honoring rev in current code (exportHtml.getPadHTML threads the parameter through), so the earlier report on that API call appears to be resolved. This PR does not touch it. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
80 lines
3.5 KiB
TypeScript
80 lines
3.5 KiB
TypeScript
'use strict';
|
|
/**
|
|
* 2014 John McLear (Etherpad Foundation / McLear Ltd)
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS-IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
const Stream = require('./Stream');
|
|
const assert = require('assert').strict;
|
|
const authorManager = require('../db/AuthorManager');
|
|
const hooks = require('../../static/js/pluginfw/hooks');
|
|
const padManager = require('../db/PadManager');
|
|
|
|
exports.getPadRaw = async (padId:string, readOnlyId:string, revNum?: number) => {
|
|
const dstPfx = `pad:${readOnlyId || padId}`;
|
|
const [pad, customPrefixes] = await Promise.all([
|
|
padManager.getPad(padId),
|
|
hooks.aCallAll('exportEtherpadAdditionalContent'),
|
|
]);
|
|
// If a rev limit was supplied, clamp to it and also clamp chat to the
|
|
// timestamp-ordered window that ended at that rev. Without this, a rev=5
|
|
// export on a pad with head=100 would still ship all 95 later revisions
|
|
// (and leak their content via the exported .etherpad file) — which is
|
|
// precisely what issue #5071 reported.
|
|
const padHead: number = pad.head;
|
|
const effectiveHead: number = (revNum == null || revNum > padHead) ? padHead : revNum;
|
|
const isRevBound = revNum != null && revNum < padHead;
|
|
const boundAtext = isRevBound ? await pad.getInternalRevisionAText(effectiveHead) : null;
|
|
const pluginRecords = await Promise.all(customPrefixes.map(async (customPrefix:string) => {
|
|
const srcPfx = `${customPrefix}:${padId}`;
|
|
const dstPfx = `${customPrefix}:${readOnlyId || padId}`;
|
|
assert(!srcPfx.includes('*'));
|
|
const srcKeys = await pad.db.findKeys(`${srcPfx}:*`, null);
|
|
return (function* () {
|
|
yield [dstPfx, pad.db.get(srcPfx)];
|
|
for (const k of srcKeys) {
|
|
assert(k.startsWith(`${srcPfx}:`));
|
|
yield [`${dstPfx}${k.slice(srcPfx.length)}`, pad.db.get(k)];
|
|
}
|
|
})();
|
|
}));
|
|
const records = (function* () {
|
|
for (const authorId of pad.getAllAuthors()) {
|
|
yield [`globalAuthor:${authorId}`, (async () => {
|
|
const authorEntry = await authorManager.getAuthor(authorId);
|
|
if (!authorEntry) return undefined; // Becomes unset when converted to JSON.
|
|
if (authorEntry.padIDs) authorEntry.padIDs = readOnlyId || padId;
|
|
return authorEntry;
|
|
})()];
|
|
}
|
|
for (let i = 0; i <= effectiveHead; ++i) yield [`${dstPfx}:revs:${i}`, pad.getRevision(i)];
|
|
for (let i = 0; i <= pad.chatHead; ++i) yield [`${dstPfx}:chat:${i}`, pad.getChatMessage(i)];
|
|
for (const gen of pluginRecords) yield* gen;
|
|
})();
|
|
// When rev-bound, serialize a shallow-cloned pad object with head/atext
|
|
// rewritten so the import side reconstructs the pad at the requested rev.
|
|
// toJSON() returns a plain object suitable for spreading; the live Pad
|
|
// instance is kept for the exportEtherpad hook below.
|
|
const serializedPad = isRevBound
|
|
? {...(pad.toJSON()), head: effectiveHead, atext: boundAtext}
|
|
: pad;
|
|
const data = {[dstPfx]: serializedPad};
|
|
for (const [dstKey, p] of new Stream(records).batch(100).buffer(99)) data[dstKey] = await p;
|
|
await hooks.aCallAll('exportEtherpad', {
|
|
pad,
|
|
data,
|
|
dstPadId: readOnlyId || padId,
|
|
});
|
|
return data;
|
|
};
|