Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions bin/compactAllPads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
'use strict';

/*
* Compact every pad on the instance to reclaim database space.
*
* Usage:
* node bin/compactAllPads.js # collapse all history on every pad
* node bin/compactAllPads.js --keep N # keep last N revisions per pad
* node bin/compactAllPads.js --dry-run # list pads + rev counts, no writes
*
* Composes the existing `listAllPads` and `compactPad` HTTP APIs — there is
* deliberately no instance-wide HTTP endpoint, because doing this over a
* single request would mean one giant response and a long-held connection.
* Per-pad failures don't stop the run; they're logged and counted, and the
* exit code reflects whether anything failed.
*
* Destructive — `getEtherpad`-export anything you can't afford to lose
* before running.
*
* Issue #6194: per-instance bulk compaction. The per-pad `bin/compactPad`
* is the right tool when you know which pad is fat; this is the right tool
* when you want to reclaim space across the whole instance.
*/
import path from 'node:path';
import fs from 'node:fs';
import process from 'node:process';
import axios from 'axios';

export type CompactAllOpts = {
keepRevisions: number | null;
dryRun: boolean;
};

// Minimal interface mirroring the API endpoints the script needs. Tests
// substitute their own implementation that goes through supertest+JWT
// instead of axios+APIKEY, so the loop logic is exercised against a real
// running server without dragging in apikey-file or axios setup.
export type CompactAllApi = {
listAllPads(): Promise<string[]>;
getRevisionsCount(padId: string): Promise<number>;
compactPad(padId: string, keepRevisions: number | null): Promise<void>;
};

export type CompactAllReport = {
total: number;
ok: number;
failed: number;
totalRevsBefore: number;
totalRevsAfter: number;
};

export type CompactAllLogger = {
info(msg: string): void;
error(msg: string): void;
};

const defaultLogger: CompactAllLogger = {
info: (m) => console.log(m),
error: (m) => console.error(m),
};

// Pure-ish core: composition + per-pad error tolerance + dry-run + tally.
// Returns a structured report so tests can assert on outcomes; the CLI
// shell maps it to an exit code.
export const runCompactAll = async (
api: CompactAllApi, opts: CompactAllOpts,
logger: CompactAllLogger = defaultLogger,
): Promise<CompactAllReport> => {
let padIds: string[];
try {
padIds = await api.listAllPads();
} catch (e: any) {
logger.error(`listAllPads failed: ${e.message ?? e}`);
return {total: 0, ok: 0, failed: 1, totalRevsBefore: 0, totalRevsAfter: 0};
}

if (padIds.length === 0) {
logger.info('No pads on this instance.');
return {total: 0, ok: 0, failed: 0, totalRevsBefore: 0, totalRevsAfter: 0};
}

const strategy = opts.keepRevisions == null
? 'collapse all history'
: `keep last ${opts.keepRevisions} revisions`;
logger.info(`Found ${padIds.length} pad(s). Strategy: ${strategy}` +
`${opts.dryRun ? ' (dry run — no writes)' : ''}.`);

const report: CompactAllReport = {
total: padIds.length, ok: 0, failed: 0,
totalRevsBefore: 0, totalRevsAfter: 0,
};

for (let i = 0; i < padIds.length; i++) {
const padId = padIds[i];
const idx = `[${i + 1}/${padIds.length}]`;

let before: number;
try {
before = await api.getRevisionsCount(padId);
} catch (e: any) {
logger.error(`${idx} ${padId}: getRevisionsCount failed: ${e.message ?? e}`);
report.failed++;
continue;
}

if (opts.dryRun) {
logger.info(`${idx} ${padId}: ${before + 1} revision(s) — would compact`);
report.totalRevsBefore += before + 1;
continue;
}

try {
await api.compactPad(padId, opts.keepRevisions);
} catch (e: any) {
logger.error(`${idx} ${padId}: compactPad failed: ${e.message ?? e}`);
report.failed++;
continue;
}

let after: number | undefined;
try { after = await api.getRevisionsCount(padId); }
catch { /* main op already succeeded; post-count is informational */ }

if (after != null) {
logger.info(`${idx} ${padId}: ${before + 1} → ${after + 1} revision(s)`);
report.totalRevsBefore += before + 1;
report.totalRevsAfter += after + 1;
} else {
logger.info(`${idx} ${padId}: compacted (post-count unavailable)`);
}
report.ok++;
}

if (opts.dryRun) {
logger.info('');
logger.info(`Dry run complete. ${padIds.length} pad(s), ` +
`${report.totalRevsBefore} total revision(s) — re-run ` +
'without --dry-run to compact.');
} else {
logger.info('');
logger.info(`Done. ${report.ok} pad(s) compacted, ${report.failed} failed. ` +
`Revisions: ${report.totalRevsBefore} → ${report.totalRevsAfter} ` +
`(reclaimed ${report.totalRevsBefore - report.totalRevsAfter}).`);
}

return report;
};

export const parseArgs = (argv: string[]): CompactAllOpts | null => {
const opts: CompactAllOpts = {keepRevisions: null, dryRun: false};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--dry-run') {
opts.dryRun = true;
} else if (a === '--keep') {
const v = argv[++i];
const n = Number(v);
if (!Number.isInteger(n) || n < 0) {
console.error(`--keep expects a non-negative integer; got ${v}`);
return null;
}
opts.keepRevisions = n;
} else {
return null;
}
}
return opts;
};

// CLI entry point. Skipped when this file is imported (e.g. by tests),
// so the test harness can use `runCompactAll` directly without network.
const usage = () => {
console.error('Usage:');
console.error(' node bin/compactAllPads.js');
console.error(' node bin/compactAllPads.js --keep <N>');
console.error(' node bin/compactAllPads.js --dry-run');
process.exit(2);
};

const isMain = require.main === module;
if (isMain) {
process.on('unhandledRejection', (err) => { throw err; });

const settings = require('ep_etherpad-lite/tests/container/loadSettings').loadSettings();
axios.defaults.baseURL =
`${settings.ssl ? 'https' : 'http'}://${settings.ip}:${settings.port}`;

const opts = parseArgs(process.argv.slice(2));
if (!opts) usage();

const apikey = fs.readFileSync(
path.join(__dirname, '../APIKEY.txt'), {encoding: 'utf-8'}).trim();

// Bind the abstract API to axios + APIKEY auth for the CLI shell.
const cliApi: CompactAllApi = {
async listAllPads() {
const apiInfo = await axios.get('/api/');
const apiVersion: string | undefined = apiInfo.data.currentVersion;
if (!apiVersion) throw new Error('No version set in API');
// Stash on this for subsequent calls. Avoids a per-call /api/ ping.
(cliApi as any)._apiVersion = apiVersion;
const r = await axios.get(`/api/${apiVersion}/listAllPads?apikey=${apikey}`);
if (r.data.code !== 0) throw new Error(JSON.stringify(r.data));
return r.data.data.padIDs ?? [];
},
async getRevisionsCount(padId: string) {
const v = (cliApi as any)._apiVersion;
const r = await axios.get(
`/api/${v}/getRevisionsCount?apikey=${apikey}` +
`&padID=${encodeURIComponent(padId)}`);
if (r.data.code !== 0) throw new Error(JSON.stringify(r.data));
return r.data.data.revisions;
},
async compactPad(padId: string, keepRevisions: number | null) {
const v = (cliApi as any)._apiVersion;
const params = new URLSearchParams({apikey, padID: padId});
if (keepRevisions != null) params.set('keepRevisions', String(keepRevisions));
const r = await axios.post(`/api/${v}/compactPad?${params.toString()}`);
if (r.data.code !== 0) throw new Error(JSON.stringify(r.data));
},
};

(async () => {
const report = await runCompactAll(cliApi, opts!);
if (report.failed > 0) process.exit(1);
})();
}
92 changes: 92 additions & 0 deletions bin/compactPad.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
'use strict';

/*
* Compact a pad's revision history to reclaim database space.
*
* Usage:
* node bin/compactPad.js <padID> # collapse all history
* node bin/compactPad.js <padID> --keep N # keep only the last N revisions
*
* Wraps the existing Cleanup helper (src/node/utils/Cleanup.ts) via the
* compactPad HTTP API so admins can trigger it from the CLI without
* routing through the admin settings UI. Destructive — export the pad as
* `.etherpad` first for backup.
*
* Issue #6194: long-lived pads with heavy edit history accumulate hundreds
* of megabytes in the DB; this tool is the per-pad brick for reclaiming
* that space without rotating to a new pad ID.
*/
import path from 'node:path';
import fs from 'node:fs';
import process from 'node:process';
import axios from 'axios';

// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
process.on('unhandledRejection', (err) => { throw err; });

const settings = require('ep_etherpad-lite/tests/container/loadSettings').loadSettings();

axios.defaults.baseURL =
`${settings.ssl ? 'https' : 'http'}://${settings.ip}:${settings.port}`;

Comment on lines +28 to +32
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Cli hardcodes http:// baseurl 📘 Rule violation ☼ Reliability

The new CLI sets axios.defaults.baseURL to http://... even though the server supports SSL
(settings.ssl). This can fail in SSL-enabled deployments and violates the protocol-independence
requirement for URLs meant to work in both HTTP and HTTPS environments.
Agent Prompt
## Issue description
The CLI uses a hardcoded `http://` base URL, which breaks HTTPS/SSL setups and violates the protocol-independence requirement.

## Issue Context
The server supports SSL via `settings.ssl`, and other code builds URLs using `settings.ssl ? 'https' : 'http'`.

## Fix Focus Areas
- bin/compactPad.ts[28-31]
- src/node/hooks/express/openapi.ts[767-771]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

const usage = () => {
console.error('Usage:');
console.error(' node bin/compactPad.js <padID>');
console.error(' node bin/compactPad.js <padID> --keep <N>');
process.exit(2);
};

const args = process.argv.slice(2);
if (args.length < 1 || args.length > 3) usage();
const padId = args[0];

let keepRevisions: number | null = null;
if (args.length === 3) {
if (args[1] !== '--keep') usage();
keepRevisions = Number(args[2]);
if (!Number.isInteger(keepRevisions) || keepRevisions < 0) {
console.error(`--keep expects a non-negative integer; got ${args[2]}`);
process.exit(2);
}
}

// get the API Key
const filePath = path.join(__dirname, '../APIKEY.txt');
const apikey = fs.readFileSync(filePath, {encoding: 'utf-8'}).trim();

(async () => {
const apiInfo = await axios.get('/api/');
const apiVersion: string | undefined = apiInfo.data.currentVersion;
if (!apiVersion) throw new Error('No version set in API');

// Pre-flight: show current revision count so operators can eyeball impact.
const countUri = `/api/${apiVersion}/getRevisionsCount?apikey=${apikey}&padID=${padId}`;
const countRes = await axios.get(countUri);
if (countRes.data.code !== 0) {
console.error(`getRevisionsCount failed: ${JSON.stringify(countRes.data)}`);
process.exit(1);
}
const before: number = countRes.data.data.revisions;
const strategy = keepRevisions == null ? 'collapse all' : `keep last ${keepRevisions}`;
console.log(`Pad ${padId}: ${before + 1} revision(s). Strategy: ${strategy}.`);

const params = new URLSearchParams({apikey, padID: padId});
if (keepRevisions != null) params.set('keepRevisions', String(keepRevisions));
const result = await axios.post(`/api/${apiVersion}/compactPad?${params.toString()}`);
if (result.data.code !== 0) {
console.error(`compactPad failed: ${JSON.stringify(result.data)}`);
process.exit(1);
}

// Post-flight: the pad is now compacted. Re-read the rev count so the
// operator sees concrete savings.
const afterRes = await axios.get(countUri);
const after: number | undefined = afterRes.data?.data?.revisions;
if (after != null) {
console.log(`Done. Pad ${padId}: ${after + 1} revision(s) remaining ` +
`(was ${before + 1}).`);
} else {
console.log('Done.');
}
})();
2 changes: 2 additions & 0 deletions bin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"makeDocs": "node --import tsx make_docs.ts",
"checkPad": "node --import tsx checkPad.ts",
"checkAllPads": "node --import tsx checkAllPads.ts",
"compactPad": "node --import tsx compactPad.ts",
"compactAllPads": "node --import tsx compactAllPads.ts",
"createUserSession": "node --import tsx createUserSession.ts",
"deletePad": "node --import tsx deletePad.ts",
"repairPad": "node --import tsx repairPad.ts",
Expand Down
21 changes: 20 additions & 1 deletion doc/api/http_api.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Portal submits content into new blog post
=== Usage

==== API version
The latest version is `1.3.0`
The latest version is `1.3.1`

The current version can be queried via /api.

Expand Down Expand Up @@ -588,6 +588,25 @@ _Example returns:_
* `{code: 0, message:"ok", data: null}`
* `{code: 1, message:"padID does not exist", data: null}`

==== compactPad(padID, [keepRevisions])
* API >= 1.3.1

collapses the pad's revision history to reclaim database space (issue #6194). Wraps the same `Cleanup` helper that powers the admin-settings UI, so admins can trigger compaction over the public API or via `bin/compactPad` without going through the admin UI.

*Gated on `settings.cleanup.enabled = true`* (matches the admin/Cleanup path). The endpoint returns an error if cleanup isn't enabled in `settings.json`, so the public API can't bypass the same opt-in switch the admin UI requires.

When `keepRevisions` is omitted (or null), all history is collapsed into a single base revision that reproduces the current pad text — equivalent to a freshly-imported pad. When set to a positive integer N, the pad keeps only its last N revisions.

Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. *This operation is destructive — export the pad first via `getEtherpad` if you need a backup.*

_Example returns:_

* `{code: 0, message:"ok", data: {ok: true, mode: "all"}}`
* `{code: 0, message:"ok", data: {ok: true, mode: "keepLast", keepRevisions: 50}}`
* `{code: 1, message:"padID does not exist", data: null}`
* `{code: 1, message:"keepRevisions must be a non-negative integer", data: null}`
* `{code: 1, message:"compactPad requires cleanup.enabled = true in settings.json", data: null}`

==== getReadOnlyID(padID)
* API >= 1

Expand Down
20 changes: 19 additions & 1 deletion doc/api/http_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Portal submits content into new blog post
## Usage

### API version
The latest version is `1.3.0`
The latest version is `1.3.1`

The current version can be queried via /api.

Expand Down Expand Up @@ -637,6 +637,24 @@ moves a pad. If force is true and the destination pad exists, it will be overwri
* `{code: 0, message:"ok", data: null}`
* `{code: 1, message:"padID does not exist", data: null}`

#### compactPad(padID, [keepRevisions])
* API >= 1.3.1

collapses the pad's revision history to reclaim database space (issue #6194). Wraps the same `Cleanup` helper that powers the admin-settings UI, so admins can trigger compaction over the public API or via `bin/compactPad` without going through the admin UI.

**Gated on `settings.cleanup.enabled = true`** (matches the admin/Cleanup path). The endpoint returns an error if cleanup isn't enabled in `settings.json`, so the public API can't bypass the same opt-in switch the admin UI requires.

When `keepRevisions` is omitted (or null), all history is collapsed into a single base revision that reproduces the current pad text — equivalent to a freshly-imported pad. When set to a positive integer N, the pad keeps only its last N revisions.

Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. **This operation is destructive — export the pad first via `getEtherpad` if you need a backup.**

*Example returns:*
* `{code: 0, message:"ok", data: {ok: true, mode: "all"}}`
* `{code: 0, message:"ok", data: {ok: true, mode: "keepLast", keepRevisions: 50}}`
* `{code: 1, message:"padID does not exist", data: null}`
* `{code: 1, message:"keepRevisions must be a non-negative integer", data: null}`
* `{code: 1, message:"compactPad requires cleanup.enabled = true in settings.json", data: null}`

#### getReadOnlyID(padID)
* API >= 1

Expand Down
Loading
Loading