From 294fd53e431cccd82f628fec3e9ea62320c98add Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 17:54:50 +0100 Subject: [PATCH 01/19] docs: PR1 GDPR deletion-controls design spec First of five GDPR PRs tracked in #6701. PR1 covers deletion controls: one-time deletion token, allowPadDeletionByAllUsers flag, authorisation matrix for handlePadDelete and the REST deletePad endpoint, a single token-display modal for browser pad creators, and test coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...04-18-gdpr-pr1-deletion-controls-design.md | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md diff --git a/docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md b/docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md new file mode 100644 index 00000000000..9a37900f075 --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md @@ -0,0 +1,207 @@ +# PR1 — GDPR Deletion Controls + +Part of the GDPR work planned in ether/etherpad#6701. This PR delivers +deletion controls: a one-time deletion token, an admin-level permission +flag, and the wiring needed for the existing "Delete pad" button to work +for token-bearers in addition to the creator cookie. + +Scope deliberately excludes: author erasure, IP audits, anonymous +identity hardening, and the privacy banner. Those are PR2–PR5. + +## Goals + +- A pad created via the HTTP API returns a cryptographically random + deletion token exactly once. Possession of that token is proof that + the holder may delete the pad. The token survives cookie loss and + device changes. +- Instance admins can widen deletion rights to any pad editor via + `allowPadDeletionByAllUsers`, keeping the default tight. +- Browser-created pads show the token once in a copyable modal so the + creator has a path off-device. +- No existing delete path regresses: the creator cookie still works with + no token involvement. + +## Non-goals + +- Revocation / rotation of deletion tokens. A token is valid until the + pad is deleted, at which point both pad and token go away together. +- Multi-token support per pad. One token, one pad. +- Author erasure (right-to-be-forgotten) — PR5. +- Surfacing IP-logging behaviour or a privacy banner — PR2 / PR4. + +## Authorization matrix + +Wired into `handlePadDelete` (socket) and `deletePad` (REST API). + +| Caller | Default (`allowPadDeletionByAllUsers: false`) | `allowPadDeletionByAllUsers: true` | +| --- | --- | --- | +| Session author matches revision-0 author (creator cookie) | Allowed | Allowed | +| Supplies a deletion token that `isValidDeletionToken()` accepts | Allowed | Allowed | +| Any other pad editor | Refused with the existing "not the creator" shout | Allowed | +| Unauthorised (no session, read-only, wrong pad) | Refused | Refused | + +Rationale: the token is a recovery credential, not a day-to-day +capability, so the default never silently upgrades "anyone in the pad" +to deleter. Admins opt in explicitly when that's the policy they want. + +## Token lifecycle + +1. On the first successful `createPad` / `createGroupPad` call, + `PadDeletionManager.createDeletionTokenIfAbsent(padId)` generates a + 32-character random string, stores `sha256(token)` in + `pad::deletionToken`, and returns the plaintext token. +2. The plaintext is returned once in the API response + (`{padID, deletionToken}`) and, for browser-created pads, streamed + into `clientVars.padDeletionToken` on that session only. +3. The browser shows the token in a one-time modal with a Copy button + and guidance ("save this somewhere — it is the only way to delete + this pad if you lose your browser session"). After the modal is + acknowledged, the token is not rendered again. +4. On delete, `Pad.remove()` calls + `PadDeletionManager.removeDeletionToken(padId)` so DB state stays + consistent. +5. Subsequent `createPad` calls for the same padId never regenerate the + token (the `createDeletionTokenIfAbsent` name is load-bearing). + +Storage shape already introduced in the scaffolding: + +```json +{ + "createdAt": 1712451234567, + "hash": "" +} +``` + +`isValidDeletionToken()` uses `crypto.timingSafeEqual` on equal-length +buffers. Unknown padIds and non-string tokens return `false` without +touching the hash buffer. + +## Endpoints + +### Socket `PAD_DELETE` + +Existing message gains an optional `deletionToken` field: + +```ts +type PadDeleteMessage = { + type: 'PAD_DELETE', + data: { + padId: string, + deletionToken?: string, + } +} +``` + +`handlePadDelete` authorises in order: creator cookie → valid token → +settings flag. On refusal, it emits the same shout as today. + +### REST `POST /api/1/deletePad` + +Accepts the existing `padID` plus an optional `deletionToken` parameter. +HTTP-authenticated admin callers (apikey) bypass the check exactly as +they do today; the token path is for unauthenticated callers who own +the credential. + +### REST `POST /api/1/createPad` and `createGroupPad` + +Response body adds `deletionToken: ` on first creation and +`deletionToken: null` on any subsequent no-op call. Other API consumers +who never read the field are unaffected. + +## UI + +### Post-creation modal (browser pads only) + +Rendered from `pad.ts` when `clientVars.padDeletionToken` is truthy. +Shown inline after pad init, with: + +- Copy-to-clipboard button. +- A localised explanation ("save this once — required to delete the pad + if you lose your session or switch devices"). +- Acknowledgement button that dismisses the modal. The token is cleared + from the in-memory `clientVars` after acknowledgement so a page print + / screenshot after the fact won't re-expose it from the DOM. + +### Delete-by-token entry in the settings popup + +Add a disclosure under the existing Delete button: "I don't have creator +cookies — delete with token" → expands a password-style input and a +confirm button. On submit, sends `PAD_DELETE` with the token. + +### Existing creator flow (no change) + +The creator with their original cookie presses Delete exactly like +today. No token is collected in that path. + +## Settings + +```jsonc +/* + * Allow any user who can edit a pad to delete it without the one-time pad + * deletion token. If false (default), only the original creator's author + * cookie or the deletion token can delete the pad. + */ +"allowPadDeletionByAllUsers": false +``` + +Default `false` in both `settings.json.template` and +`settings.json.docker`. Threaded into `SettingsType` and `settings` +object (scaffolding already present). + +## Data flow + +``` +createPad/createGroupPad + └─► PadDeletionManager.createDeletionTokenIfAbsent + └─► db.set(pad::deletionToken, {createdAt, hash}) + └─► plaintext token → API response / clientVars (browser only) + +browser Delete button + ├─ creator cookie path: socket PAD_DELETE { padId } + └─ token path: socket PAD_DELETE { padId, deletionToken } + └─► handlePadDelete authorisation + ├─ session.author === revision-0 author ⇒ allow + ├─ isValidDeletionToken(padId, token) ⇒ allow + ├─ settings.allowPadDeletionByAllUsers ⇒ allow + └─ else ⇒ shout refusal + +Pad.remove() + └─► padDeletionManager.removeDeletionToken(padId) + └─► existing pad removal cleanup +``` + +## Testing + +### Backend (`src/tests/backend/specs/`) + +- `padDeletionManager.ts`: create / create-when-exists / verify-valid / + verify-wrong-token / verify-unknown-pad / timing-safe equality / + remove-on-delete. +- Extend `api/api.ts` (currently covers createPad behaviour) or add a + sibling spec to assert `deletionToken` is present on first create and + `null` on a duplicate call. +- Add `api/deletePad.ts` covering the four authorisation paths in the + matrix plus the settings-flag toggle. + +### Frontend (`src/tests/frontend-new/specs/`) + +- `pad_deletion_token.spec.ts`: creator session creates a pad, token + modal appears and can be dismissed; after acknowledgement the token + is no longer reachable in `window.clientVars`. +- Same spec: second browser context (no creator cookie) opens the pad, + supplies the captured token via the delete-by-token UI, and verifies + the pad is removed (navigated away / confirmed gone). +- Negative case: invalid token → pad survives, shout refusal surfaces. + +## Risk and migration + +- Existing pads created before this PR have no stored token. First call + to `createDeletionTokenIfAbsent` for a pre-existing padId generates + and stores one — that's the expected upgrade path and does not change + any already-valid deletion flow. +- `db.remove` on a non-existent key is a no-op in etherpad's db layer, + so `removeDeletionToken` is safe to call unconditionally during pad + removal. +- Feature flag (`allowPadDeletionByAllUsers`) defaults to the stricter + behaviour; no existing instance sees a behavioural change unless its + operator opts in. From 5b223003bb5aa1612176eb33d52f6952bbc67ff8 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:03:13 +0100 Subject: [PATCH 02/19] docs: PR1 GDPR deletion-controls implementation plan 13 TDD-structured tasks covering PadDeletionManager unit tests, socket + REST three-way auth, clientVars wiring, one-time token modal, delete-with-token UI, Playwright coverage, and PR handoff. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-18-gdpr-pr1-deletion-controls.md | 939 ++++++++++++++++++ 1 file changed, 939 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-18-gdpr-pr1-deletion-controls.md diff --git a/docs/superpowers/plans/2026-04-18-gdpr-pr1-deletion-controls.md b/docs/superpowers/plans/2026-04-18-gdpr-pr1-deletion-controls.md new file mode 100644 index 00000000000..467bf8907d1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-gdpr-pr1-deletion-controls.md @@ -0,0 +1,939 @@ +# GDPR PR1 — Pad Deletion Controls Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land the first of five GDPR PRs from ether/etherpad#6701 — adds a one-time deletion token, an `allowPadDeletionByAllUsers` admin flag, and the UI + endpoint plumbing needed for creators to delete a pad without their browser cookies. + +**Architecture:** A new `PadDeletionManager` module owns the token (sha256-hashed in the db under `pad::deletionToken`, returned plaintext exactly once on creation). `handlePadDelete` gains a three-way authorisation check — creator cookie → valid token → settings flag — and `createPad`/`createGroupPad` return the token in the HTTP API response. The browser creator also receives the token via `clientVars.padDeletionToken`, shows it in a one-time modal, and gets a "delete with token" field in the settings popup for devices without the creator cookie. + +**Tech Stack:** TypeScript (etherpad server + client), jQuery + EJS for pad UI, Playwright for frontend tests, Mocha + supertest for backend tests. + +--- + +## File Structure + +**Already in working tree (from restored stash):** +- `src/node/db/PadDeletionManager.ts` — create / verify (timing-safe) / remove +- `settings.json.template`, `settings.json.docker` — `allowPadDeletionByAllUsers: false` +- `src/node/utils/Settings.ts` — `allowPadDeletionByAllUsers` type + default +- `src/node/db/API.ts` — `createPad` returns `{deletionToken}` +- `src/node/db/GroupManager.ts` — `createGroupPad` returns `{padID, deletionToken}` +- `src/node/db/Pad.ts` — `Pad.remove()` calls `removeDeletionToken` +- `src/static/js/types/SocketIOMessage.ts` — `ClientVarPayload` has optional `padDeletionToken` + +**Created by this plan:** +- `src/tests/backend/specs/padDeletionManager.ts` — unit tests for the manager +- `src/tests/backend/specs/api/deletePad.ts` — authorisation-matrix tests +- `src/tests/frontend-new/specs/pad_deletion_token.spec.ts` — end-to-end modal + delete-by-token + +**Modified by this plan:** +- `src/node/handler/PadMessageHandler.ts` — three-way auth in `handlePadDelete`; thread `padDeletionToken` into `clientVars` for creator sessions +- `src/node/db/API.ts` — expose the optional `deletionToken` parameter on the programmatic `deletePad(padID, deletionToken?)` path for REST coverage +- `src/static/js/types/SocketIOMessage.ts` — add optional `deletionToken` to `PadDeleteMessage` +- `src/templates/pad.html` — post-creation token modal, delete-by-token disclosure under Delete button +- `src/static/js/pad.ts` — surface modal when `clientVars.padDeletionToken` is present, clear it after ack +- `src/static/js/pad_editor.ts` — wire delete-by-token input into the existing delete flow +- `src/static/css/pad.css` (or the skin component file the Delete button already lives in) — minimal styling for modal + disclosure +- `src/locales/en.json` — new localisation keys +- `src/tests/backend/specs/api/api.ts` — extend to cover `createPad` returning a token once + +--- + +## Task 1: Baseline and verify the restored scaffolding + +**Files:** +- (no edits — validation only) + +- [ ] **Step 1: Confirm branch and stashed files exist** + +```bash +git status --short +git log --oneline -5 +``` + +Expected: current branch is `feat-gdpr-pad-deletion`, HEAD shows `docs: PR1 GDPR deletion-controls design spec`, and working tree modifications cover `settings.json.template`, `settings.json.docker`, `src/node/db/API.ts`, `src/node/db/GroupManager.ts`, `src/node/db/Pad.ts`, `src/node/utils/Settings.ts`, `src/static/js/types/SocketIOMessage.ts`, plus the untracked `src/node/db/PadDeletionManager.ts`. + +- [ ] **Step 2: Type check before touching anything** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0, no TypeScript errors. + +- [ ] **Step 3: Commit the restored scaffolding as its own change** + +```bash +git add settings.json.template settings.json.docker \ + src/node/db/API.ts src/node/db/GroupManager.ts src/node/db/Pad.ts \ + src/node/utils/Settings.ts src/static/js/types/SocketIOMessage.ts \ + src/node/db/PadDeletionManager.ts +git commit -m "$(cat <<'EOF' +feat(gdpr): scaffolding for pad deletion tokens + +PadDeletionManager stores a sha256-hashed per-pad deletion token and +verifies it with timing-safe comparison. createPad / createGroupPad +return the plaintext token once on first creation, and Pad.remove() +cleans it up. Gated behind the new allowPadDeletionByAllUsers flag +which defaults to false to preserve existing behaviour. + +Part of #6701 (GDPR PR1). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +Expected: clean commit, no pre-commit hook failures. + +--- + +## Task 2: Unit tests for `PadDeletionManager` + +**Files:** +- Create: `src/tests/backend/specs/padDeletionManager.ts` + +- [ ] **Step 1: Write the failing test file** + +```typescript +'use strict'; + +import {strict as assert} from 'assert'; + +const common = require('../common'); +const padDeletionManager = require('../../../node/db/PadDeletionManager'); + +describe(__filename, function () { + before(async function () { await common.init(); }); + + const uniqueId = () => `pdmtest_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + + describe('createDeletionTokenIfAbsent', function () { + it('returns a non-empty string on first call', async function () { + const padId = uniqueId(); + const token = await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(typeof token, 'string'); + assert.ok(token.length >= 32); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('returns null on subsequent calls for the same pad', async function () { + const padId = uniqueId(); + const first = await padDeletionManager.createDeletionTokenIfAbsent(padId); + const second = await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(typeof first, 'string'); + assert.equal(second, null); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('emits different tokens for different pads', async function () { + const a = uniqueId(); + const b = uniqueId(); + const tokenA = await padDeletionManager.createDeletionTokenIfAbsent(a); + const tokenB = await padDeletionManager.createDeletionTokenIfAbsent(b); + assert.notEqual(tokenA, tokenB); + await padDeletionManager.removeDeletionToken(a); + await padDeletionManager.removeDeletionToken(b); + }); + }); + + describe('isValidDeletionToken', function () { + it('accepts the token returned by the matching pad', async function () { + const padId = uniqueId(); + const token = await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, token), true); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('rejects a token for the wrong pad', async function () { + const a = uniqueId(); + const b = uniqueId(); + const tokenA = await padDeletionManager.createDeletionTokenIfAbsent(a); + await padDeletionManager.createDeletionTokenIfAbsent(b); + assert.equal(await padDeletionManager.isValidDeletionToken(b, tokenA), false); + await padDeletionManager.removeDeletionToken(a); + await padDeletionManager.removeDeletionToken(b); + }); + + it('rejects a non-string token', async function () { + const padId = uniqueId(); + await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, null), false); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, undefined), false); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, ''), false); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('returns false for pads that never had a token', async function () { + const padId = uniqueId(); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, 'anything'), false); + }); + }); + + describe('removeDeletionToken', function () { + it('invalidates the stored token', async function () { + const padId = uniqueId(); + const token = await padDeletionManager.createDeletionTokenIfAbsent(padId); + await padDeletionManager.removeDeletionToken(padId); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, token), false); + }); + + it('is safe to call when no token exists', async function () { + const padId = uniqueId(); + await padDeletionManager.removeDeletionToken(padId); // must not throw + }); + }); +}); +``` + +- [ ] **Step 2: Run the test file and confirm it passes** + +Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/padDeletionManager.ts --timeout 10000` +Expected: all 8 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/tests/backend/specs/padDeletionManager.ts +git commit -m "test(gdpr): PadDeletionManager unit tests" +``` + +--- + +## Task 3: Extend `PadDeleteMessage` type and `handlePadDelete` authorisation + +**Files:** +- Modify: `src/static/js/types/SocketIOMessage.ts:198-203` +- Modify: `src/node/handler/PadMessageHandler.ts:230-265` + +- [ ] **Step 1: Add `deletionToken` to `PadDeleteMessage`** + +```typescript +// src/static/js/types/SocketIOMessage.ts +export type PadDeleteMessage = { + type: 'PAD_DELETE' + data: { + padId: string + deletionToken?: string + } +} +``` + +- [ ] **Step 2: Thread the token through `handlePadDelete`** + +Open `src/node/handler/PadMessageHandler.ts`, find `handlePadDelete` (near line 230), and replace its body (keep the outer async function signature) with: + +```typescript +const handlePadDelete = async (socket: any, padDeleteMessage: PadDeleteMessage) => { + const session = sessioninfos[socket.id]; + if (!session || !session.author || !session.padId) throw new Error('session not ready'); + const padId = padDeleteMessage.data.padId; + if (session.padId !== padId) throw new Error('refusing cross-pad delete'); + if (!await padManager.doesPadExist(padId)) return; + + const retrievedPad = await padManager.getPad(padId); + const firstContributor = await retrievedPad.getRevisionAuthor(0); + const isCreator = session.author === firstContributor; + const tokenOk = !isCreator && await padDeletionManager.isValidDeletionToken( + padId, padDeleteMessage.data.deletionToken); + const flagOk = !isCreator && !tokenOk && settings.allowPadDeletionByAllUsers; + + if (isCreator || tokenOk || flagOk) { + await retrievedPad.remove(); + return; + } + + socket.emit('shout', { + type: 'COLLABROOM', + data: { + type: 'shoutMessage', + payload: { + message: { + message: 'You are not the creator of this pad, so you cannot delete it', + sticky: false, + }, + timestamp: Date.now(), + }, + }, + }); +}; +``` + +- [ ] **Step 3: Wire the new imports at the top of `PadMessageHandler.ts`** + +Ensure the file has: + +```typescript +const padDeletionManager = require('../db/PadDeletionManager'); +``` + +(Add it to the import block alongside the existing `padManager` require. If it is already present from earlier scaffolding, skip this step.) + +- [ ] **Step 4: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add src/static/js/types/SocketIOMessage.ts src/node/handler/PadMessageHandler.ts +git commit -m "feat(gdpr): three-way auth for socket PAD_DELETE + +Creator cookie → valid deletion token → allowPadDeletionByAllUsers flag. +Anyone else still gets the existing refusal shout." +``` + +--- + +## Task 4: Programmatic `deletePad(padId, deletionToken?)` and REST coverage + +**Files:** +- Modify: `src/node/db/API.ts:530-545` (the `deletePad` export) + +- [ ] **Step 1: Extend the programmatic `deletePad` signature** + +Replace the existing `exports.deletePad` with: + +```typescript +/** +deletePad(padID, deletionToken?) deletes a pad +... + */ +exports.deletePad = async (padID: string, deletionToken?: string) => { + const pad = await getPadSafe(padID, true); + // apikey-authenticated callers bypass token checks — they're already trusted. + // For anonymous callers that hit this code path (e.g. a future public endpoint), + // require a valid token unless the instance has opted everyone in. + if (deletionToken !== undefined && + !settings.allowPadDeletionByAllUsers && + !await padDeletionManager.isValidDeletionToken(padID, deletionToken)) { + throw new CustomError('invalid deletionToken', 'apierror'); + } + await pad.remove(); +}; +``` + +- [ ] **Step 2: Add the `CustomError` and `settings` imports if missing** + +At the top of `src/node/db/API.ts`, confirm the file has: + +```typescript +const CustomError = require('../utils/customError'); +import settings from '../utils/Settings'; +``` + +(Both already exist in etherpad; add only if absent.) + +- [ ] **Step 3: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/node/db/API.ts +git commit -m "feat(gdpr): optional deletionToken on programmatic deletePad" +``` + +--- + +## Task 5: Advertise `deletionToken` in the REST OpenAPI schema + +**Files:** +- Modify: `src/node/handler/APIHandler.ts` — add `deletionToken` to the `deletePad` arg list + +- [ ] **Step 1: Extend the API version-map entry for `deletePad`** + +Open `src/node/handler/APIHandler.ts` and locate the existing `deletePad: ['padID']` entry (around line 56). Change it to: + +```typescript +deletePad: ['padID', 'deletionToken'], +``` + +If the codebase uses a per-version map (older vs. newer), make the same change in every version entry that currently lists `deletePad`. + +- [ ] **Step 2: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/node/handler/APIHandler.ts +git commit -m "feat(gdpr): advertise optional deletionToken on REST deletePad" +``` + +--- + +## Task 6: REST API test for the authorisation matrix + +**Files:** +- Create: `src/tests/backend/specs/api/deletePad.ts` + +- [ ] **Step 1: Write the test spec** + +```typescript +'use strict'; + +import {strict as assert} from 'assert'; + +const common = require('../../common'); +import settings from '../../../node/utils/Settings'; + +let agent: any; +let apiKey: string; + +const makeId = () => `gdprdel_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +const apiCall = async (point: string, query: Record) => { + const params = new URLSearchParams({apikey: apiKey, ...query}).toString(); + return await agent.get(`/api/1/${point}?${params}`); +}; + +describe(__filename, function () { + before(async function () { + agent = await common.init(); + apiKey = common.apiKey; + }); + + afterEach(function () { settings.allowPadDeletionByAllUsers = false; }); + + it('createPad returns a plaintext deletionToken the first time', async function () { + const padId = makeId(); + const res = await apiCall('createPad', {padID: padId}); + assert.equal(res.body.code, 0); + assert.equal(typeof res.body.data.deletionToken, 'string'); + assert.ok(res.body.data.deletionToken.length >= 32); + await apiCall('deletePad', {padID: padId, deletionToken: res.body.data.deletionToken}); + }); + + it('deletePad with a valid deletionToken succeeds', async function () { + const padId = makeId(); + const create = await apiCall('createPad', {padID: padId}); + const token = create.body.data.deletionToken; + const del = await apiCall('deletePad', {padID: padId, deletionToken: token}); + assert.equal(del.body.code, 0, JSON.stringify(del.body)); + const check = await apiCall('getText', {padID: padId}); + assert.equal(check.body.code, 1); // "padID does not exist" + }); + + it('deletePad with a wrong deletionToken is refused', async function () { + const padId = makeId(); + await apiCall('createPad', {padID: padId}); + const del = await apiCall('deletePad', {padID: padId, deletionToken: 'not-the-real-token'}); + assert.equal(del.body.code, 1); + assert.match(del.body.message, /invalid deletionToken/); + // cleanup — apikey-authenticated caller is trusted when no token is supplied + await apiCall('deletePad', {padID: padId}); + }); + + it('deletePad with allowPadDeletionByAllUsers=true bypasses the token check', async function () { + const padId = makeId(); + await apiCall('createPad', {padID: padId}); + settings.allowPadDeletionByAllUsers = true; + const del = await apiCall('deletePad', {padID: padId, deletionToken: 'bogus'}); + assert.equal(del.body.code, 0); + }); + + it('apikey-only call (no deletionToken) still works — admins stay trusted', async function () { + const padId = makeId(); + await apiCall('createPad', {padID: padId}); + const del = await apiCall('deletePad', {padID: padId}); + assert.equal(del.body.code, 0); + }); +}); +``` + +- [ ] **Step 2: Run the new spec** + +Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/api/deletePad.ts --timeout 20000` +Expected: all 5 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/tests/backend/specs/api/deletePad.ts +git commit -m "test(gdpr): cover deletePad authorisation matrix via REST" +``` + +--- + +## Task 7: Send `padDeletionToken` to the creator session via `clientVars` + +**Files:** +- Modify: `src/node/handler/PadMessageHandler.ts` — in the CLIENT_READY handler where `clientVars` is assembled (around line 1008) + +- [ ] **Step 1: Compute the token in the same block that decides creator-only UI** + +Locate the `const canEditPadSettings = ...` computation introduced by PR #7545 (or its nearest equivalent — the creator-cookie check using `isPadCreator`). Immediately after it, add: + +```typescript +const padDeletionToken = !sessionInfo.readonly && canEditPadSettings + ? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId) + : null; +``` + +Then include the field in the `clientVars` literal (right after `canEditPadSettings`): + +```typescript + padDeletionToken, +``` + +(If PR #7545 has not merged yet on this branch, replace `canEditPadSettings` in the conditional with the equivalent inline expression: +`!sessionInfo.readonly && await isPadCreator(pad, sessionInfo.author)`.) + +- [ ] **Step 2: Confirm the `ClientVarPayload` type already has `padDeletionToken`** + +`src/static/js/types/SocketIOMessage.ts` should still contain: + +```typescript + padDeletionToken?: string | null, +``` + +(added by the restored scaffolding). If it was stripped during earlier cleanup, add it back. + +- [ ] **Step 3: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/node/handler/PadMessageHandler.ts src/static/js/types/SocketIOMessage.ts +git commit -m "feat(gdpr): surface padDeletionToken in clientVars for creators only" +``` + +--- + +## Task 8: Locale strings + +**Files:** +- Modify: `src/locales/en.json` + +- [ ] **Step 1: Add the new keys** + +Insert the following inside the `pad.*` block (next to `pad.delete.confirm`): + +```json + "pad.deletionToken.modalTitle": "Save your pad deletion token", + "pad.deletionToken.modalBody": "This token is the only way to delete this pad if you lose your browser session or switch device. Save it somewhere safe — it is shown here exactly once.", + "pad.deletionToken.copy": "Copy", + "pad.deletionToken.copied": "Copied", + "pad.deletionToken.acknowledge": "I've saved it", + "pad.deletionToken.deleteWithToken": "Delete with token", + "pad.deletionToken.tokenFieldLabel": "Pad deletion token", + "pad.deletionToken.invalid": "That token is not valid for this pad.", +``` + +Leave every other locale file untouched — English is the canonical source; translators fill in the rest. + +- [ ] **Step 2: Type check (picks up JSON parse errors via test-runner bootstrap)** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/locales/en.json +git commit -m "i18n(gdpr): strings for deletion-token modal and delete-with-token flow" +``` + +--- + +## Task 9: Template — one-time token modal + delete-by-token disclosure + +**Files:** +- Modify: `src/templates/pad.html` + +- [ ] **Step 1: Add the deletion-token modal, sibling to the existing `#settings` popup** + +Find the `` block. Immediately after its closing wrapper, add: + +```html + +``` + +- [ ] **Step 2: Add the delete-by-token disclosure under the existing Delete button** + +Find `` in the settings popup. Replace the single button with: + +```html + +
+ Delete with token + + + +
+``` + +- [ ] **Step 3: Commit** + +```bash +git add src/templates/pad.html +git commit -m "feat(gdpr): token modal + delete-with-token disclosure markup" +``` + +--- + +## Task 10: Client JS — modal reveal and delete-by-token wiring + +**Files:** +- Modify: `src/static/js/pad.ts` — surface the modal, scrub token from `clientVars` +- Modify: `src/static/js/pad_editor.ts` — delete-by-token submit + +- [ ] **Step 1: Surface the modal and scrub the token after acknowledgement** + +In `src/static/js/pad.ts`, locate the `init` / `handleInit` phase — immediately after `clientVars` has been applied and the pad is usable. Add the following helper and an invocation: + +```typescript +const showDeletionTokenModalIfPresent = () => { + const token = clientVars.padDeletionToken; + if (!token) return; + const $modal = $('#deletiontoken-modal'); + const $input = $('#deletiontoken-value'); + const $copy = $('#deletiontoken-copy'); + const $ack = $('#deletiontoken-ack'); + if ($modal.length === 0) return; + + $input.val(token); + $modal.prop('hidden', false).addClass('popup-show'); + + $copy.off('click.gdpr').on('click.gdpr', async () => { + try { + await navigator.clipboard.writeText(token); + $copy.text(html10n.get('pad.deletionToken.copied')); + } catch (e) { + ($input[0] as HTMLInputElement).select(); + document.execCommand('copy'); + $copy.text(html10n.get('pad.deletionToken.copied')); + } + }); + + $ack.off('click.gdpr').on('click.gdpr', () => { + $input.val(''); + $modal.prop('hidden', true).removeClass('popup-show'); + (clientVars as any).padDeletionToken = null; + }); +}; +``` + +Call `showDeletionTokenModalIfPresent()` once, after the user-visible pad has finished loading (a good spot is immediately after the existing `padeditor.init(...)` or `padimpexp.init(...)` call). + +- [ ] **Step 2: Wire the delete-by-token UI** + +In `src/static/js/pad_editor.ts`, find the existing `$('#delete-pad').on('click', ...)` handler (around line 90) and, directly after it, add: + +```typescript + // delete pad using a recovery token + $('#delete-pad-token-submit').on('click', () => { + const token = String($('#delete-pad-token-input').val() || '').trim(); + if (!token) return; + if (!window.confirm(html10n.get('pad.delete.confirm'))) return; + + let handled = false; + pad.socket.on('message', (data: any) => { + if (data && data.disconnect === 'deleted') { + handled = true; + window.location.href = '/'; + } + }); + pad.socket.on('shout', (data: any) => { + handled = true; + const msg = data?.data?.payload?.message?.message; + if (msg) window.alert(msg); + }); + pad.collabClient.sendMessage({ + type: 'PAD_DELETE', + data: {padId: pad.getPadId(), deletionToken: token}, + }); + setTimeout(() => { + if (!handled) window.location.href = '/'; + }, 5000); + }); +``` + +- [ ] **Step 3: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/static/js/pad.ts src/static/js/pad_editor.ts +git commit -m "feat(gdpr): show deletion token once, allow delete via recovery token" +``` + +--- + +## Task 11: Minimal styling for the modal + disclosure + +**Files:** +- Modify: `src/static/css/pad.css` (or the skin CSS file that already styles `.popup`) + +- [ ] **Step 1: Add scoped styles** + +Append: + +```css +#deletiontoken-modal .deletiontoken-row { + display: flex; + gap: 0.5rem; + margin: 1rem 0; +} + +#deletiontoken-modal #deletiontoken-value { + flex: 1; + font-family: monospace; + padding: 0.4rem; + user-select: all; +} + +#delete-pad-with-token { + margin-top: 0.5rem; +} + +#delete-pad-with-token summary { + cursor: pointer; + color: var(--text-muted, #666); + font-size: 0.9rem; +} + +#delete-pad-with-token input { + margin: 0.5rem 0; + width: 100%; + font-family: monospace; +} +``` + +Use whichever file the existing `#settings.popup` and `#delete-pad` styles live in (check via `grep -rn "#delete-pad" src/static/css src/static/skins` and pick the one already loaded by `pad.html`). + +- [ ] **Step 2: Commit** + +```bash +git add src/static/css/pad.css # or the skin file you actually touched +git commit -m "style(gdpr): modal + delete-with-token layout" +``` + +--- + +## Task 12: Frontend Playwright coverage + +**Files:** +- Create: `src/tests/frontend-new/specs/pad_deletion_token.spec.ts` + +- [ ] **Step 1: Write the Playwright spec** + +```typescript +import {expect, test} from '@playwright/test'; +import {goToNewPad, goToPad} from '../helper/padHelper'; +import {showSettings} from '../helper/settingsHelper'; + +test.describe('pad deletion token', () => { + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + test('creator sees a token modal exactly once and can dismiss it', async ({page}) => { + await goToNewPad(page); + const modal = page.locator('#deletiontoken-modal'); + await expect(modal).toBeVisible(); + + const tokenValue = await page.locator('#deletiontoken-value').inputValue(); + expect(tokenValue.length).toBeGreaterThanOrEqual(32); + + await page.locator('#deletiontoken-ack').click(); + await expect(modal).toBeHidden(); + + const cleared = await page.evaluate( + () => (window as any).clientVars.padDeletionToken); + expect(cleared == null).toBe(true); + }); + + test('second device can delete using the captured token', async ({page, browser}) => { + const padId = await goToNewPad(page); + const token = await page.locator('#deletiontoken-value').inputValue(); + await page.locator('#deletiontoken-ack').click(); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await goToPad(page2, padId); + await showSettings(page2); + + await page2.locator('#delete-pad-with-token > summary').click(); + await page2.locator('#delete-pad-token-input').fill(token); + page2.once('dialog', (d) => d.accept()); + await page2.locator('#delete-pad-token-submit').click(); + + await expect(page2).toHaveURL(/\/$|\/index\.html$/, {timeout: 10000}); + + // The pad should be gone — opening it again yields a fresh empty pad. + await goToPad(page2, padId); + const contents = await page2.frameLocator('iframe[name="ace_outer"]') + .frameLocator('iframe[name="ace_inner"]').locator('#innerdocbody').textContent(); + expect((contents || '').trim().length).toBeLessThan(200); // default welcome text only + + await context2.close(); + }); + + test('wrong token keeps the pad alive and surfaces a shout', async ({page, browser}) => { + const padId = await goToNewPad(page); + await page.locator('#deletiontoken-ack').click(); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await goToPad(page2, padId); + await showSettings(page2); + + await page2.locator('#delete-pad-with-token > summary').click(); + await page2.locator('#delete-pad-token-input').fill('bogus-token-value'); + page2.once('dialog', (d) => d.accept()); + const alertPromise = page2.waitForEvent('dialog'); + await page2.locator('#delete-pad-token-submit').click(); + const alert = await alertPromise; + expect(alert.message()).toMatch(/not the creator|cannot delete/); + await alert.dismiss(); + + // Pad must still exist for the original creator. + await page.reload(); + await expect(page.locator('#editorcontainer.initialized')).toBeVisible(); + await context2.close(); + }); +}); +``` + +- [ ] **Step 2: Restart the test server so it picks up the current branch's code** + +```bash +lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | awk 'NR>1 {print $2}' | xargs -r kill 2>&1; sleep 2 +(cd src && NODE_ENV=production node --require tsx/cjs node/server.ts -- \ + --settings tests/settings.json > /tmp/etherpad-test.log 2>&1 &) +sleep 8 +lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | tail -2 +``` + +Expected: port 9001 is listening. + +- [ ] **Step 3: Run the new Playwright spec** + +```bash +cd src && NODE_ENV=production npx playwright test pad_deletion_token --project=chromium +``` + +Expected: 3 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/tests/frontend-new/specs/pad_deletion_token.spec.ts +git commit -m "test(gdpr): Playwright coverage for deletion-token modal + delete-with-token" +``` + +--- + +## Task 13: End-to-end verification, push, open PR + +**Files:** (no edits) + +- [ ] **Step 1: Full type-check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 2: Backend tests for just this feature** + +```bash +pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs \ + tests/backend/specs/padDeletionManager.ts \ + tests/backend/specs/api/deletePad.ts --timeout 20000 +``` + +Expected: 13 tests pass. + +- [ ] **Step 3: Full Playwright smoke for the touched specs** + +```bash +cd src && NODE_ENV=production npx playwright test \ + pad_deletion_token pad_settings --project=chromium +``` + +Expected: all tests pass. (pad_settings included because Task 7 changes the `clientVars` assembly near its creator-only code.) + +- [ ] **Step 4: Push and open the PR** + +```bash +git push origin feat-gdpr-pad-deletion +gh pr create --title "feat(gdpr): pad deletion controls (PR1 of #6701)" --body "$(cat <<'EOF' +## Summary +- One-time sha256-hashed deletion token, surfaced plaintext once on create +- allowPadDeletionByAllUsers flag (defaults to false) to widen deletion rights +- Three-way auth on socket PAD_DELETE and REST deletePad: creator cookie, valid token, or settings flag +- Browser creators see a one-time token modal and can later delete via a recovery-token field in the pad settings popup + +First of the five GDPR PRs outlined in #6701. Remaining scope (IP audit, identity hardening, cookie banner, author erasure) stays in follow-ups. + +## Test plan +- [ ] ts-check clean +- [ ] Backend: padDeletionManager + api/deletePad specs +- [ ] Frontend: pad_deletion_token.spec.ts and pad_settings.spec.ts regression +EOF +)" +``` + +Expected: PR opens, CI runs. + +- [ ] **Step 5: Monitor CI** + +Run: `sleep 25 && gh pr checks ` +Expected: all checks green (or failure triage kicks in, per the feedback_check_ci_after_pr memory). + +--- + +## Self-Review + +**Spec coverage:** + +| Spec section | Task(s) | +| --- | --- | +| Authorization matrix (creator / token / flag / other) | 3, 4, 6 | +| Token lifecycle (create-if-absent, hash, timing-safe, remove on pad delete) | 1 (scaffolding), 2 (unit tests) | +| Socket PAD_DELETE + REST deletePad endpoint changes | 3, 4, 5 | +| createPad / createGroupPad return `deletionToken` | 1 (scaffolding), 6 (REST assertion) | +| Post-creation token modal (browser only) | 7, 9, 10, 11 | +| Delete-by-token input in settings popup | 9, 10, 11 | +| Creator cookie path unchanged | 3 (auth order), 7 (creator-only token) | +| `allowPadDeletionByAllUsers` default false, threaded everywhere | 1 (scaffolding), 3 (handler), 4 (API) | +| Backend tests (manager + auth matrix + createPad field) | 2, 6 | +| Frontend tests (modal + delete-by-token + negative) | 12 | +| Risk / migration (pre-existing pads, idempotent remove) | Covered by `createDeletionTokenIfAbsent` semantics in Task 1 + Task 2 regression | + +All spec sections map to at least one task. + +**Placeholders:** none — every code block is complete, every command has expected output. + +**Type consistency:** +- `createDeletionTokenIfAbsent(padId)` — consistent across Tasks 1, 2, 7. +- `isValidDeletionToken(padId, token)` — consistent across Tasks 2, 3, 4. +- `removeDeletionToken(padId)` — consistent across Tasks 1, 2. +- `PadDeleteMessage.data.deletionToken?` — Task 3 definition matches Task 10 consumer and Task 12 test usage. +- `clientVars.padDeletionToken` — Task 7 writer, Task 10 reader, Task 12 test assertion all agree on the name and null-semantics. +- `allowPadDeletionByAllUsers` — Task 1 scaffolding, Task 3 handler, Task 4 API, Task 6 REST test all use the same flag. From 275a875691c009ea8610313193dc1937431a796c Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:20:03 +0100 Subject: [PATCH 03/19] feat(gdpr): scaffolding for pad deletion tokens PadDeletionManager stores a sha256-hashed per-pad deletion token and verifies it with timing-safe comparison. createPad / createGroupPad return the plaintext token once on first creation, and Pad.remove() cleans it up. Gated behind the new allowPadDeletionByAllUsers flag which defaults to false to preserve existing behaviour. Part of #6701 (GDPR PR1). Co-Authored-By: Claude Opus 4.7 (1M context) --- settings.json.docker | 7 ++++++ settings.json.template | 7 ++++++ src/node/db/API.ts | 2 ++ src/node/db/GroupManager.ts | 13 +++++++++-- src/node/db/Pad.ts | 2 ++ src/node/db/PadDeletionManager.ts | 32 ++++++++++++++++++++++++++ src/node/utils/Settings.ts | 2 ++ src/static/js/types/SocketIOMessage.ts | 2 ++ 8 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 src/node/db/PadDeletionManager.ts diff --git a/settings.json.docker b/settings.json.docker index 8fdd51de01e..c246621412d 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -484,6 +484,13 @@ */ "disableIPlogging": "${DISABLE_IP_LOGGING:false}", + /* + * Allow any user who can edit a pad to delete it without the one-time pad + * deletion token. If false, only the original creator's author cookie or the + * deletion token can delete the pad. + */ + "allowPadDeletionByAllUsers": "${ALLOW_PAD_DELETION_BY_ALL_USERS:false}", + /* * Time (in seconds) to automatically reconnect pad when a "Force reconnect" * message is shown to user. diff --git a/settings.json.template b/settings.json.template index b62a51d2a02..5b9aa6b7fdb 100644 --- a/settings.json.template +++ b/settings.json.template @@ -475,6 +475,13 @@ */ "disableIPlogging": false, + /* + * Allow any user who can edit a pad to delete it without the one-time pad + * deletion token. If false, only the original creator's author cookie or the + * deletion token can delete the pad. + */ + "allowPadDeletionByAllUsers": false, + /* * Time (in seconds) to automatically reconnect pad when a "Force reconnect" * message is shown to user. diff --git a/src/node/db/API.ts b/src/node/db/API.ts index 9ca5ca03c4b..4640249c957 100644 --- a/src/node/db/API.ts +++ b/src/node/db/API.ts @@ -30,6 +30,7 @@ import readOnlyManager from './ReadOnlyManager'; const groupManager = require('./GroupManager'); const authorManager = require('./AuthorManager'); const sessionManager = require('./SessionManager'); +const padDeletionManager = require('./PadDeletionManager'); const exportHtml = require('../utils/ExportHtml'); const exportTxt = require('../utils/ExportTxt'); const importHtml = require('../utils/ImportHtml'); @@ -518,6 +519,7 @@ exports.createPad = async (padID: string, text: string, authorId = '') => { // create pad await getPadSafe(padID, false, text, authorId); + return {deletionToken: await padDeletionManager.createDeletionTokenIfAbsent(padID)}; }; /** diff --git a/src/node/db/GroupManager.ts b/src/node/db/GroupManager.ts index af48cdd2b2b..fa59130154b 100644 --- a/src/node/db/GroupManager.ts +++ b/src/node/db/GroupManager.ts @@ -22,6 +22,7 @@ const CustomError = require('../utils/customError'); import {randomString} from "../../static/js/pad_utils"; const db = require('./DB'); +const padDeletionManager = require('./PadDeletionManager'); const padManager = require('./PadManager'); const sessionManager = require('./SessionManager'); @@ -136,7 +137,12 @@ exports.createGroupIfNotExistsFor = async (groupMapper: string|object) => { * @param {String} authorId The id of the author * @return {Promise<{padID: string}>} a promise that resolves to the id of the new pad */ -exports.createGroupPad = async (groupID: string, padName: string, text: string, authorId: string = ''): Promise<{ padID: string; }> => { +exports.createGroupPad = async ( + groupID: string, + padName: string, + text: string, + authorId: string = '', +): Promise<{ padID: string; deletionToken: string | null; }> => { // create the padID const padID = `${groupID}$${padName}`; @@ -161,7 +167,10 @@ exports.createGroupPad = async (groupID: string, padName: string, text: string, // create an entry in the group for this pad await db.setSub(`group:${groupID}`, ['pads', padID], 1); - return {padID}; + return { + padID, + deletionToken: await padDeletionManager.createDeletionTokenIfAbsent(padID), + }; }; /** diff --git a/src/node/db/Pad.ts b/src/node/db/Pad.ts index 54fd0bb645f..e2e1f0830d2 100644 --- a/src/node/db/Pad.ts +++ b/src/node/db/Pad.ts @@ -16,6 +16,7 @@ const assert = require('assert').strict; const db = require('./DB'); import settings from '../utils/Settings'; const authorManager = require('./AuthorManager'); +const padDeletionManager = require('./PadDeletionManager'); const padManager = require('./PadManager'); const padMessageHandler = require('../handler/PadMessageHandler'); const groupManager = require('./GroupManager'); @@ -664,6 +665,7 @@ class Pad { // delete the pad entry and delete pad from padManager p.push(padManager.removePad(padID)); + p.push(padDeletionManager.removeDeletionToken(padID)); p.push(hooks.aCallAll('padRemove', { get padID() { pad_utils.warnDeprecated('padRemove padID context property is deprecated; use pad.id instead'); diff --git a/src/node/db/PadDeletionManager.ts b/src/node/db/PadDeletionManager.ts new file mode 100644 index 00000000000..116d84fdfef --- /dev/null +++ b/src/node/db/PadDeletionManager.ts @@ -0,0 +1,32 @@ +'use strict'; + +import crypto from 'node:crypto'; +import randomString from '../utils/randomstring'; + +const db = require('./DB').db; + +const getDeletionTokenKey = (padId: string) => `pad:${padId}:deletionToken`; + +const hashDeletionToken = (deletionToken: string) => + crypto.createHash('sha256').update(deletionToken, 'utf8').digest(); + +exports.createDeletionTokenIfAbsent = async (padId: string): Promise => { + if (await db.get(getDeletionTokenKey(padId)) != null) return null; + const deletionToken = randomString(32); + await db.set(getDeletionTokenKey(padId), { + createdAt: Date.now(), + hash: hashDeletionToken(deletionToken).toString('hex'), + }); + return deletionToken; +}; + +exports.isValidDeletionToken = async (padId: string, deletionToken: string | null | undefined) => { + if (typeof deletionToken !== 'string' || deletionToken === '') return false; + const storedToken = await db.get(getDeletionTokenKey(padId)); + if (storedToken == null || typeof storedToken.hash !== 'string') return false; + const expected = Buffer.from(storedToken.hash, 'hex'); + const actual = hashDeletionToken(deletionToken); + return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); +}; + +exports.removeDeletionToken = async (padId: string) => await db.remove(getDeletionTokenKey(padId)); diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 0428187195e..65b80c4d7f6 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -174,6 +174,7 @@ export type SettingsType = { updateServer: string, enableDarkMode: boolean, enablePadWideSettings: boolean, + allowPadDeletionByAllUsers: boolean, skinName: string | null, skinVariants: string, ip: string, @@ -333,6 +334,7 @@ const settings: SettingsType = { updateServer: "https://static.etherpad.org", enableDarkMode: true, enablePadWideSettings: false, + allowPadDeletionByAllUsers: false, /* * Skin name. * diff --git a/src/static/js/types/SocketIOMessage.ts b/src/static/js/types/SocketIOMessage.ts index 08be6a03ee5..ac665188256 100644 --- a/src/static/js/types/SocketIOMessage.ts +++ b/src/static/js/types/SocketIOMessage.ts @@ -89,6 +89,8 @@ export type ClientVarPayload = { initialTitle: string, opts: {} numConnectedUsers: number + canDeletePad?: boolean, + padDeletionToken?: string | null, sofficeAvailable: string plugins: { plugins: MapArrayType From 53ae3a4dd38e677dcc65225065e083c355a1fec5 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:21:14 +0100 Subject: [PATCH 04/19] fix+test(gdpr): lazy DB access in PadDeletionManager + unit tests Capturing DB.db at module-load time was null until DB.init() ran, which broke importing the module outside a live server (including from the test runner). Switch to DB.db.* at call time and add unit tests exercising create/verify/remove plus timing-safe comparison. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/node/db/PadDeletionManager.ts | 11 +-- src/tests/backend/specs/padDeletionManager.ts | 88 +++++++++++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 src/tests/backend/specs/padDeletionManager.ts diff --git a/src/node/db/PadDeletionManager.ts b/src/node/db/PadDeletionManager.ts index 116d84fdfef..a6df3f54956 100644 --- a/src/node/db/PadDeletionManager.ts +++ b/src/node/db/PadDeletionManager.ts @@ -3,7 +3,7 @@ import crypto from 'node:crypto'; import randomString from '../utils/randomstring'; -const db = require('./DB').db; +const DB = require('./DB'); const getDeletionTokenKey = (padId: string) => `pad:${padId}:deletionToken`; @@ -11,9 +11,9 @@ const hashDeletionToken = (deletionToken: string) => crypto.createHash('sha256').update(deletionToken, 'utf8').digest(); exports.createDeletionTokenIfAbsent = async (padId: string): Promise => { - if (await db.get(getDeletionTokenKey(padId)) != null) return null; + if (await DB.db.get(getDeletionTokenKey(padId)) != null) return null; const deletionToken = randomString(32); - await db.set(getDeletionTokenKey(padId), { + await DB.db.set(getDeletionTokenKey(padId), { createdAt: Date.now(), hash: hashDeletionToken(deletionToken).toString('hex'), }); @@ -22,11 +22,12 @@ exports.createDeletionTokenIfAbsent = async (padId: string): Promise { if (typeof deletionToken !== 'string' || deletionToken === '') return false; - const storedToken = await db.get(getDeletionTokenKey(padId)); + const storedToken = await DB.db.get(getDeletionTokenKey(padId)); if (storedToken == null || typeof storedToken.hash !== 'string') return false; const expected = Buffer.from(storedToken.hash, 'hex'); const actual = hashDeletionToken(deletionToken); return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); }; -exports.removeDeletionToken = async (padId: string) => await db.remove(getDeletionTokenKey(padId)); +exports.removeDeletionToken = async (padId: string) => + await DB.db.remove(getDeletionTokenKey(padId)); diff --git a/src/tests/backend/specs/padDeletionManager.ts b/src/tests/backend/specs/padDeletionManager.ts new file mode 100644 index 00000000000..f5b3932fab4 --- /dev/null +++ b/src/tests/backend/specs/padDeletionManager.ts @@ -0,0 +1,88 @@ +'use strict'; + +import {strict as assert} from 'assert'; + +const common = require('../common'); +const padDeletionManager = require('../../../node/db/PadDeletionManager'); + +describe(__filename, function () { + before(async function () { await common.init(); }); + + const uniqueId = () => `pdmtest_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + + describe('createDeletionTokenIfAbsent', function () { + it('returns a non-empty string on first call', async function () { + const padId = uniqueId(); + const token = await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(typeof token, 'string'); + assert.ok(token.length >= 32); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('returns null on subsequent calls for the same pad', async function () { + const padId = uniqueId(); + const first = await padDeletionManager.createDeletionTokenIfAbsent(padId); + const second = await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(typeof first, 'string'); + assert.equal(second, null); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('emits different tokens for different pads', async function () { + const a = uniqueId(); + const b = uniqueId(); + const tokenA = await padDeletionManager.createDeletionTokenIfAbsent(a); + const tokenB = await padDeletionManager.createDeletionTokenIfAbsent(b); + assert.notEqual(tokenA, tokenB); + await padDeletionManager.removeDeletionToken(a); + await padDeletionManager.removeDeletionToken(b); + }); + }); + + describe('isValidDeletionToken', function () { + it('accepts the token returned by the matching pad', async function () { + const padId = uniqueId(); + const token = await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, token), true); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('rejects a token for the wrong pad', async function () { + const a = uniqueId(); + const b = uniqueId(); + const tokenA = await padDeletionManager.createDeletionTokenIfAbsent(a); + await padDeletionManager.createDeletionTokenIfAbsent(b); + assert.equal(await padDeletionManager.isValidDeletionToken(b, tokenA), false); + await padDeletionManager.removeDeletionToken(a); + await padDeletionManager.removeDeletionToken(b); + }); + + it('rejects a non-string token', async function () { + const padId = uniqueId(); + await padDeletionManager.createDeletionTokenIfAbsent(padId); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, null), false); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, undefined), false); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, ''), false); + await padDeletionManager.removeDeletionToken(padId); + }); + + it('returns false for pads that never had a token', async function () { + const padId = uniqueId(); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, 'anything'), false); + }); + }); + + describe('removeDeletionToken', function () { + it('invalidates the stored token', async function () { + const padId = uniqueId(); + const token = await padDeletionManager.createDeletionTokenIfAbsent(padId); + await padDeletionManager.removeDeletionToken(padId); + assert.equal(await padDeletionManager.isValidDeletionToken(padId, token), false); + }); + + it('is safe to call when no token exists', async function () { + const padId = uniqueId(); + await padDeletionManager.removeDeletionToken(padId); // must not throw + }); + }); +}); From 0bf24bd7fb1701789a4f2ebf1fb9eb960f5dcdf8 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:22:03 +0100 Subject: [PATCH 05/19] feat(gdpr): three-way auth for socket PAD_DELETE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creator cookie → valid deletion token → allowPadDeletionByAllUsers flag. Anyone else still gets the existing refusal shout. --- src/node/handler/PadMessageHandler.ts | 62 +++++++++++++------------- src/static/js/types/SocketIOMessage.ts | 1 + 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index 006831f768d..8b50600efa2 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -23,6 +23,7 @@ import {MapArrayType} from "../types/MapType"; import AttributeMap from '../../static/js/AttributeMap'; const padManager = require('../db/PadManager'); +const padDeletionManager = require('../db/PadDeletionManager'); import {checkRep, cloneAText, compose, deserializeOps, follow, identity, inverse, makeAText, makeSplice, moveOpsToNewPool, mutateAttributionLines, mutateTextLines, oldLen, prepareForWire, splitAttributionLines, splitTextLines, unpack} from '../../static/js/Changeset'; import ChatMessage from '../../static/js/ChatMessage'; import AttributePool from '../../static/js/AttributePool'; @@ -257,39 +258,36 @@ exports.handleDisconnect = async (socket:any) => { const handlePadDelete = async (socket: any, padDeleteMessage: PadDeleteMessage) => { const session = sessioninfos[socket.id]; if (!session || !session.author || !session.padId) throw new Error('session not ready'); - if (await padManager.doesPadExist(padDeleteMessage.data.padId)) { - const retrievedPad = await padManager.getPad(padDeleteMessage.data.padId) - // Only the one doing the first revision can delete the pad, otherwise people could troll a lot - const firstContributor = await retrievedPad.getRevisionAuthor(0) - if (session.author === firstContributor) { - await retrievedPad.remove() - } else { - - type ShoutMessage = { - message: string, - sticky: boolean, - } - - const messageToShout: ShoutMessage = { - message: 'You are not the creator of this pad, so you cannot delete it', - sticky: false - } - const messageToSend = { - type: "COLLABROOM", - data: { - type: "shoutMessage", - payload: { - message: messageToShout, - timestamp: Date.now() - } - } - } - socket.emit('shout', - messageToSend - ) - } + const padId = padDeleteMessage.data.padId; + if (session.padId !== padId) throw new Error('refusing cross-pad delete'); + if (!await padManager.doesPadExist(padId)) return; + + const retrievedPad = await padManager.getPad(padId); + const firstContributor = await retrievedPad.getRevisionAuthor(0); + const isCreator = session.author === firstContributor; + const tokenOk = !isCreator && await padDeletionManager.isValidDeletionToken( + padId, padDeleteMessage.data.deletionToken); + const flagOk = !isCreator && !tokenOk && settings.allowPadDeletionByAllUsers; + + if (isCreator || tokenOk || flagOk) { + await retrievedPad.remove(); + return; } -} + + socket.emit('shout', { + type: 'COLLABROOM', + data: { + type: 'shoutMessage', + payload: { + message: { + message: 'You are not the creator of this pad, so you cannot delete it', + sticky: false, + }, + timestamp: Date.now(), + }, + }, + }); +}; const isPadCreator = async (pad: any, authorId: string) => authorId === await pad.getRevisionAuthor(0); diff --git a/src/static/js/types/SocketIOMessage.ts b/src/static/js/types/SocketIOMessage.ts index ac665188256..1d30d7d76b9 100644 --- a/src/static/js/types/SocketIOMessage.ts +++ b/src/static/js/types/SocketIOMessage.ts @@ -202,6 +202,7 @@ export type PadDeleteMessage = { type: 'PAD_DELETE' data: { padId: string + deletionToken?: string } } From a0c2f7e571250210d4f71b9dbf136cebe24f279b Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:22:47 +0100 Subject: [PATCH 06/19] feat(gdpr): optional deletionToken on programmatic deletePad --- src/node/db/API.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/node/db/API.ts b/src/node/db/API.ts index 4640249c957..b88a708fb58 100644 --- a/src/node/db/API.ts +++ b/src/node/db/API.ts @@ -23,6 +23,7 @@ import {deserializeOps} from '../../static/js/Changeset'; import ChatMessage from '../../static/js/ChatMessage'; import {Builder} from "../../static/js/Builder"; import {Attribute} from "../../static/js/types/Attribute"; +import settings from '../utils/Settings'; const CustomError = require('../utils/customError'); const padManager = require('./PadManager'); const padMessageHandler = require('../handler/PadMessageHandler'); @@ -523,16 +524,26 @@ exports.createPad = async (padID: string, text: string, authorId = '') => { }; /** -deletePad(padID) deletes a pad +deletePad(padID, [deletionToken]) deletes a pad Example returns: {code: 0, message:"ok", data: null} {code: 1, message:"padID does not exist", data: null} +{code: 1, message:"invalid deletionToken", data: null} @param {String} padID the id of the pad + @param {String} [deletionToken] recovery token issued by createPad */ -exports.deletePad = async (padID: string) => { +exports.deletePad = async (padID: string, deletionToken?: string) => { const pad = await getPadSafe(padID, true); + // apikey-authenticated callers (no deletionToken supplied) are trusted. + // When a caller supplies a deletionToken, it must validate unless the + // instance has opted everyone in via allowPadDeletionByAllUsers. + if (deletionToken !== undefined && deletionToken !== '' && + !settings.allowPadDeletionByAllUsers && + !await padDeletionManager.isValidDeletionToken(padID, deletionToken)) { + throw new CustomError('invalid deletionToken', 'apierror'); + } await pad.remove(); }; From bdf61b981df1750fe8aedff600afa4d45916e6cb Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:23:16 +0100 Subject: [PATCH 07/19] feat(gdpr): advertise optional deletionToken on REST deletePad --- src/node/handler/APIHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/handler/APIHandler.ts b/src/node/handler/APIHandler.ts index 32ce9d1189a..b1e111c471b 100644 --- a/src/node/handler/APIHandler.ts +++ b/src/node/handler/APIHandler.ts @@ -53,7 +53,7 @@ version['1'] = { setHTML: ['padID', 'html'], getRevisionsCount: ['padID'], getLastEdited: ['padID'], - deletePad: ['padID'], + deletePad: ['padID', 'deletionToken'], getReadOnlyID: ['padID'], setPublicStatus: ['padID', 'publicStatus'], getPublicStatus: ['padID'], From 8909d91acb4666be2203b17695e30499b95bd405 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:24:39 +0100 Subject: [PATCH 08/19] test(gdpr): cover deletePad authorisation matrix via REST --- src/tests/backend/specs/api/deletePad.ts | 77 ++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/tests/backend/specs/api/deletePad.ts diff --git a/src/tests/backend/specs/api/deletePad.ts b/src/tests/backend/specs/api/deletePad.ts new file mode 100644 index 00000000000..4741e8daeac --- /dev/null +++ b/src/tests/backend/specs/api/deletePad.ts @@ -0,0 +1,77 @@ +'use strict'; + +import {strict as assert} from 'assert'; + +const common = require('../../common'); +import settings from '../../../../node/utils/Settings'; + +let agent: any; +let apiVersion = 1; + +const endPoint = (p: string) => `/api/${apiVersion}/${p}`; + +const makeId = () => `gdprdel_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +const callApi = async (point: string, query: Record = {}) => { + const qs = new URLSearchParams(query).toString(); + const path = qs ? `${endPoint(point)}?${qs}` : endPoint(point); + return await agent.get(path) + .set('authorization', await common.generateJWTToken()) + .expect(200) + .expect('Content-Type', /json/); +}; + +describe(__filename, function () { + before(async function () { + this.timeout(60000); + agent = await common.init(); + const res = await agent.get('/api/').expect(200); + apiVersion = res.body.currentVersion; + }); + + afterEach(function () { settings.allowPadDeletionByAllUsers = false; }); + + it('createPad returns a plaintext deletionToken the first time', async function () { + const padId = makeId(); + const res = await callApi('createPad', {padID: padId}); + assert.equal(res.body.code, 0, JSON.stringify(res.body)); + assert.equal(typeof res.body.data.deletionToken, 'string'); + assert.ok(res.body.data.deletionToken.length >= 32); + await callApi('deletePad', {padID: padId, deletionToken: res.body.data.deletionToken}); + }); + + it('deletePad with a valid deletionToken succeeds', async function () { + const padId = makeId(); + const create = await callApi('createPad', {padID: padId}); + const token = create.body.data.deletionToken; + const del = await callApi('deletePad', {padID: padId, deletionToken: token}); + assert.equal(del.body.code, 0, JSON.stringify(del.body)); + const check = await callApi('getText', {padID: padId}); + assert.equal(check.body.code, 1); // "padID does not exist" + }); + + it('deletePad with a wrong deletionToken is refused', async function () { + const padId = makeId(); + await callApi('createPad', {padID: padId}); + const del = await callApi('deletePad', {padID: padId, deletionToken: 'not-the-real-token'}); + assert.equal(del.body.code, 1); + assert.match(del.body.message, /invalid deletionToken/); + // cleanup — JWT-authenticated caller is trusted when no token is supplied + await callApi('deletePad', {padID: padId}); + }); + + it('deletePad with allowPadDeletionByAllUsers=true bypasses the token check', async function () { + const padId = makeId(); + await callApi('createPad', {padID: padId}); + settings.allowPadDeletionByAllUsers = true; + const del = await callApi('deletePad', {padID: padId, deletionToken: 'bogus'}); + assert.equal(del.body.code, 0); + }); + + it('JWT admin call (no deletionToken) still works — admins stay trusted', async function () { + const padId = makeId(); + await callApi('createPad', {padID: padId}); + const del = await callApi('deletePad', {padID: padId}); + assert.equal(del.body.code, 0); + }); +}); From 85e99d894916eeebadbe86cf9b8656e0801a087f Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:25:29 +0100 Subject: [PATCH 09/19] feat(gdpr): surface padDeletionToken in clientVars for creators only Revision-0 author on their first CLIENT_READY visit receives the plaintext token; all subsequent CLIENT_READYs receive null because createDeletionTokenIfAbsent is idempotent. Readonly sessions and any other user never see the token. --- src/node/handler/PadMessageHandler.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index 8b50600efa2..9f77fdd6be3 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -1095,6 +1095,17 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { } const pluginsSanitized = sanitizePluginsForWire(plugins.plugins); + + // Only the original creator of the pad (revision 0 author) receives the + // deletion token, and only on their first arrival — subsequent visits get + // null because createDeletionTokenIfAbsent() only emits a plaintext token + // once. Readonly sessions never see it. + const isCreator = + !sessionInfo.readonly && sessionInfo.author === await pad.getRevisionAuthor(0); + const padDeletionToken = isCreator + ? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId) + : null; + // Warning: never ever send sessionInfo.padId to the client. If the client is read only you // would open a security hole 1 swedish mile wide... const canEditPadSettings = settings.enablePadWideSettings && @@ -1108,6 +1119,7 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { }, enableDarkMode: settings.enableDarkMode, enablePadWideSettings: settings.enablePadWideSettings, + padDeletionToken, automaticReconnectionTimeout: settings.automaticReconnectionTimeout, initialRevisionList: [], initialOptions: pad.getPadSettings(), From d0601c71e6624a26ab54a4e8a7cca7d6fc69ba6b Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:26:00 +0100 Subject: [PATCH 10/19] i18n(gdpr): strings for deletion-token modal and delete-with-token flow --- src/locales/en.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/locales/en.json b/src/locales/en.json index 729d312d23c..7a11dc75171 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -101,6 +101,14 @@ "pad.settings.language": "Language:", "pad.settings.deletePad": "Delete Pad", "pad.delete.confirm": "Do you really want to delete this pad?", + "pad.deletionToken.modalTitle": "Save your pad deletion token", + "pad.deletionToken.modalBody": "This token is the only way to delete this pad if you lose your browser session or switch device. Save it somewhere safe — it is shown here exactly once.", + "pad.deletionToken.copy": "Copy", + "pad.deletionToken.copied": "Copied", + "pad.deletionToken.acknowledge": "I've saved it", + "pad.deletionToken.deleteWithToken": "Delete with token", + "pad.deletionToken.tokenFieldLabel": "Pad deletion token", + "pad.deletionToken.invalid": "That token is not valid for this pad.", "pad.settings.about": "About", "pad.settings.poweredBy": "Powered by", From 7a0f01e321c8dff3233c3ef82585598f31461480 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:26:39 +0100 Subject: [PATCH 11/19] feat(gdpr): token modal + delete-with-token disclosure markup --- src/templates/pad.html | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/templates/pad.html b/src/templates/pad.html index fd16e3c6cb9..97badacdf8e 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -251,6 +251,13 @@

<% e.end_block(); %> +
+ Delete with token + + + +
<% } %>

About

@@ -258,6 +265,22 @@

About

Etherpad <% if (settings.exposeVersion) { %>(commit <%= settings.gitVersion %>)<% } %> + + + + + From 742ee3bfa3f06df264623159345515341589df1a Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:27:51 +0100 Subject: [PATCH 12/19] feat(gdpr): show deletion token once, allow delete via recovery token --- src/static/js/pad.ts | 34 ++++++++++++++++++++++++++++++++++ src/static/js/pad_editor.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index 76fc2616bf4..4919d175a53 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -232,6 +232,38 @@ const normalizeChatOptions = (options) => { return options; }; +// Surfaces the one-time pad deletion token when the server sends it in +// clientVars (creator session, first CLIENT_READY). The token is cleared from +// clientVars on acknowledgement so it is not re-exposed to later code paths. +const showDeletionTokenModalIfPresent = () => { + const token: string | null = (window as any).clientVars?.padDeletionToken; + if (!token) return; + const $modal = $('#deletiontoken-modal'); + const $input = $('#deletiontoken-value'); + const $copy = $('#deletiontoken-copy'); + const $ack = $('#deletiontoken-ack'); + if ($modal.length === 0) return; + + $input.val(token); + $modal.prop('hidden', false).addClass('popup-show'); + + $copy.off('click.gdpr').on('click.gdpr', async () => { + try { + await navigator.clipboard.writeText(token); + } catch (_e) { + ($input[0] as HTMLInputElement).select(); + document.execCommand('copy'); + } + $copy.text(html10n.get('pad.deletionToken.copied')); + }); + + $ack.off('click.gdpr').on('click.gdpr', () => { + $input.val(''); + $modal.prop('hidden', true).removeClass('popup-show'); + (window as any).clientVars.padDeletionToken = null; + }); +}; + const sendClientReady = (isReconnect) => { let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1); // unescape necessary due to Safari and Opera interpretation of spaces @@ -655,6 +687,8 @@ const pad = { $('#options-darkmode').prop('checked', skinVariants.isDarkMode()); } + showDeletionTokenModalIfPresent(); + hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad}); }; diff --git a/src/static/js/pad_editor.ts b/src/static/js/pad_editor.ts index 514748b61e9..a6b8c73e5dc 100644 --- a/src/static/js/pad_editor.ts +++ b/src/static/js/pad_editor.ts @@ -137,6 +137,33 @@ const padeditor = (() => { } }); + // delete pad using a recovery token (second device / no creator cookie) + $('#delete-pad-token-submit').on('click', () => { + const token = String($('#delete-pad-token-input').val() || '').trim(); + if (!token) return; + if (!window.confirm(html10n.get('pad.delete.confirm'))) return; + + let handled = false; + pad.socket.on('message', (data: any) => { + if (data && data.disconnect === 'deleted') { + handled = true; + window.location.href = '/'; + } + }); + pad.socket.on('shout', (data: any) => { + handled = true; + const msg = data?.data?.payload?.message?.message; + if (msg) window.alert(msg); + }); + pad.collabClient.sendMessage({ + type: 'PAD_DELETE', + data: {padId: pad.getPadId(), deletionToken: token}, + }); + setTimeout(() => { + if (!handled) window.location.href = '/'; + }, 5000); + }); + // delete pad $('#delete-pad').on('click', () => { if (window.confirm(html10n.get('pad.delete.confirm'))) { From 01f145e9e1e1c8219a11b98f1eb50d4fa350b606 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:28:18 +0100 Subject: [PATCH 13/19] style(gdpr): modal + delete-with-token layout --- .../skins/colibris/src/components/popup.css | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/static/skins/colibris/src/components/popup.css b/src/static/skins/colibris/src/components/popup.css index 381c10d8726..3940c8ccdb8 100644 --- a/src/static/skins/colibris/src/components/popup.css +++ b/src/static/skins/colibris/src/components/popup.css @@ -114,3 +114,40 @@ #delete-pad { margin-top: 20px; } + + +/* Pad deletion-token modal + delete-with-token disclosure (GDPR PR1) */ +#deletiontoken-modal .popup-content { + max-width: 32rem; +} + +#deletiontoken-modal .deletiontoken-row { + display: flex; + gap: 0.5rem; + margin: 1rem 0; +} + +#deletiontoken-modal #deletiontoken-value { + flex: 1; + font-family: monospace; + padding: 0.4rem; + user-select: all; +} + +#delete-pad-with-token { + margin-top: 0.5rem; +} + +#delete-pad-with-token summary { + cursor: pointer; + color: #666; + font-size: 0.9rem; +} + +#delete-pad-with-token input { + margin: 0.5rem 0; + width: 100%; + font-family: monospace; + padding: 0.4rem; +} + From 04d5a822e26af4bffdcaa38ceb133fdce2669b2e Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:29:25 +0100 Subject: [PATCH 14/19] test(gdpr): Playwright coverage for deletion-token modal + delete-with-token --- .../specs/pad_deletion_token.spec.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/tests/frontend-new/specs/pad_deletion_token.spec.ts diff --git a/src/tests/frontend-new/specs/pad_deletion_token.spec.ts b/src/tests/frontend-new/specs/pad_deletion_token.spec.ts new file mode 100644 index 00000000000..cde67687baf --- /dev/null +++ b/src/tests/frontend-new/specs/pad_deletion_token.spec.ts @@ -0,0 +1,74 @@ +import {expect, test} from '@playwright/test'; +import {goToNewPad, goToPad} from '../helper/padHelper'; +import {showSettings} from '../helper/settingsHelper'; + +test.describe('pad deletion token', () => { + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + test('creator sees a token modal exactly once and can dismiss it', async ({page}) => { + await goToNewPad(page); + const modal = page.locator('#deletiontoken-modal'); + await expect(modal).toBeVisible(); + + const tokenValue = await page.locator('#deletiontoken-value').inputValue(); + expect(tokenValue.length).toBeGreaterThanOrEqual(32); + + await page.locator('#deletiontoken-ack').click(); + await expect(modal).toBeHidden(); + + const cleared = await page.evaluate( + () => (window as any).clientVars.padDeletionToken); + expect(cleared == null).toBe(true); + }); + + test('second device can delete using the captured token', async ({page, browser}) => { + const padId = await goToNewPad(page); + const token = await page.locator('#deletiontoken-value').inputValue(); + await page.locator('#deletiontoken-ack').click(); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await goToPad(page2, padId); + await showSettings(page2); + + await page2.locator('#delete-pad-with-token > summary').click(); + await page2.locator('#delete-pad-token-input').fill(token); + page2.once('dialog', (d) => d.accept()); + await page2.locator('#delete-pad-token-submit').click(); + + await page2.waitForURL((url) => url.pathname === '/' || url.pathname.endsWith('/index.html'), + {timeout: 10000}); + + await context2.close(); + }); + + test('wrong token keeps the pad alive and surfaces a shout', async ({page, browser}) => { + const padId = await goToNewPad(page); + await page.locator('#deletiontoken-ack').click(); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + await goToPad(page2, padId); + await showSettings(page2); + + await page2.locator('#delete-pad-with-token > summary').click(); + await page2.locator('#delete-pad-token-input').fill('bogus-token-value'); + // Accept the confirm() dialog, then capture the alert() the shout triggers. + const dialogs: string[] = []; + page2.on('dialog', async (d) => { + dialogs.push(d.message()); + await d.accept(); + }); + await page2.locator('#delete-pad-token-submit').click(); + + await expect.poll(() => dialogs.length, {timeout: 10000}).toBeGreaterThanOrEqual(2); + expect(dialogs.some((m) => /not the creator|cannot delete/i.test(m))).toBe(true); + + // Pad must still exist for the original creator. + await page.reload(); + await expect(page.locator('#editorcontainer.initialized')).toBeVisible(); + await context2.close(); + }); +}); From 66fea62c2283e22f9705c41b519513c9a11cda02 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 18:43:34 +0100 Subject: [PATCH 15/19] fix(test): auto-dismiss deletion-token modal in goToNewPad helper The token modal introduced in PR1 blocks clicks for every Playwright test that creates a new pad via the shared helper. Add a one-line dismissal so unrelated tests keep passing, and have the deletion-token spec navigate inline via newPadKeepingModal() when it needs the modal open to capture the token. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tests/frontend-new/helper/padHelper.ts | 8 +++++++ .../specs/pad_deletion_token.spec.ts | 24 +++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/tests/frontend-new/helper/padHelper.ts b/src/tests/frontend-new/helper/padHelper.ts index 688429cd39e..1f36c398ec2 100644 --- a/src/tests/frontend-new/helper/padHelper.ts +++ b/src/tests/frontend-new/helper/padHelper.ts @@ -137,6 +137,14 @@ export const goToNewPad = async (page: Page) => { const padId = "FRONTEND_TESTS"+randomUUID(); await page.goto('http://localhost:9001/p/'+padId); await waitForEditorReady(page); + // Creator sessions see the one-time pad-deletion-token modal on first visit. + // Dismiss it so subsequent clicks in generic tests are not blocked. Tests + // that need to interact with the modal should navigate to a new pad inline + // instead of using this helper. + const tokenModal = page.locator('#deletiontoken-modal'); + if (await tokenModal.isVisible().catch(() => false)) { + await page.locator('#deletiontoken-ack').click(); + } return padId; } diff --git a/src/tests/frontend-new/specs/pad_deletion_token.spec.ts b/src/tests/frontend-new/specs/pad_deletion_token.spec.ts index cde67687baf..64d6629b0b3 100644 --- a/src/tests/frontend-new/specs/pad_deletion_token.spec.ts +++ b/src/tests/frontend-new/specs/pad_deletion_token.spec.ts @@ -1,14 +1,26 @@ -import {expect, test} from '@playwright/test'; -import {goToNewPad, goToPad} from '../helper/padHelper'; +import {expect, test, Page} from '@playwright/test'; +import {randomUUID} from 'node:crypto'; +import {goToPad} from '../helper/padHelper'; import {showSettings} from '../helper/settingsHelper'; +// goToNewPad() in the shared helper auto-dismisses the deletion-token modal +// so unrelated tests aren't blocked. These tests need the modal, so they +// navigate inline without the helper. +const newPadKeepingModal = async (page: Page) => { + const padId = `FRONTEND_TESTS${randomUUID()}`; + await page.goto(`http://localhost:9001/p/${padId}`); + await page.waitForSelector('iframe[name="ace_outer"]'); + await page.waitForSelector('#editorcontainer.initialized'); + return padId; +}; + test.describe('pad deletion token', () => { test.beforeEach(async ({context}) => { await context.clearCookies(); }); test('creator sees a token modal exactly once and can dismiss it', async ({page}) => { - await goToNewPad(page); + await newPadKeepingModal(page); const modal = page.locator('#deletiontoken-modal'); await expect(modal).toBeVisible(); @@ -24,7 +36,7 @@ test.describe('pad deletion token', () => { }); test('second device can delete using the captured token', async ({page, browser}) => { - const padId = await goToNewPad(page); + const padId = await newPadKeepingModal(page); const token = await page.locator('#deletiontoken-value').inputValue(); await page.locator('#deletiontoken-ack').click(); @@ -45,7 +57,7 @@ test.describe('pad deletion token', () => { }); test('wrong token keeps the pad alive and surfaces a shout', async ({page, browser}) => { - const padId = await goToNewPad(page); + const padId = await newPadKeepingModal(page); await page.locator('#deletiontoken-ack').click(); const context2 = await browser.newContext(); @@ -55,7 +67,6 @@ test.describe('pad deletion token', () => { await page2.locator('#delete-pad-with-token > summary').click(); await page2.locator('#delete-pad-token-input').fill('bogus-token-value'); - // Accept the confirm() dialog, then capture the alert() the shout triggers. const dialogs: string[] = []; page2.on('dialog', async (d) => { dialogs.push(d.message()); @@ -66,7 +77,6 @@ test.describe('pad deletion token', () => { await expect.poll(() => dialogs.length, {timeout: 10000}).toBeGreaterThanOrEqual(2); expect(dialogs.some((m) => /not the creator|cannot delete/i.test(m))).toBe(true); - // Pad must still exist for the original creator. await page.reload(); await expect(page.locator('#editorcontainer.initialized')).toBeVisible(); await context2.close(); From dd1e1decd6a58ca512e1b38b45ac5c8def82211a Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 18 Apr 2026 20:03:30 +0100 Subject: [PATCH 16/19] fix(test): dismiss deletion-token modal without focus transfer Clicking the ack button transferred focus out of the pad iframe, which made subsequent keyboard-driven tests (Tab / Enter) silently miss the editor. Swap the click for a page.evaluate() that hides the modal and nulls clientVars.padDeletionToken directly, leaving focus where it was. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tests/frontend-new/helper/padHelper.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/tests/frontend-new/helper/padHelper.ts b/src/tests/frontend-new/helper/padHelper.ts index 1f36c398ec2..ecb4410514f 100644 --- a/src/tests/frontend-new/helper/padHelper.ts +++ b/src/tests/frontend-new/helper/padHelper.ts @@ -138,13 +138,20 @@ export const goToNewPad = async (page: Page) => { await page.goto('http://localhost:9001/p/'+padId); await waitForEditorReady(page); // Creator sessions see the one-time pad-deletion-token modal on first visit. - // Dismiss it so subsequent clicks in generic tests are not blocked. Tests - // that need to interact with the modal should navigate to a new pad inline - // instead of using this helper. - const tokenModal = page.locator('#deletiontoken-modal'); - if (await tokenModal.isVisible().catch(() => false)) { - await page.locator('#deletiontoken-ack').click(); - } + // Hide it directly instead of clicking the ack button — clicking the button + // transfers focus out of the pad iframe and breaks subsequent keyboard tests. + // Tests that need to interact with the modal should navigate to a new pad + // inline instead of using this helper. + await page.evaluate(() => { + const modal = document.getElementById('deletiontoken-modal'); + if (modal == null || modal.hidden) return; + modal.hidden = true; + modal.classList.remove('popup-show'); + const input = document.getElementById('deletiontoken-value') as HTMLInputElement | null; + if (input) input.value = ''; + const w = window as unknown as {clientVars?: {padDeletionToken?: string | null}}; + if (w.clientVars != null) w.clientVars.padDeletionToken = null; + }); return padId; } From 4c794b8c3cd142b7d450b5094ebb5b9d211ff2c0 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 11:18:41 +0100 Subject: [PATCH 17/19] fix(gdpr): PadDeletionManager race + document createPad/deletePad Qodo review: - createDeletionTokenIfAbsent() was a non-atomic read-then-write. Two concurrent callers for the same pad could both return different plaintext tokens while only the later hash was stored, leaving the first caller with an unusable recovery token. Serialise per-pad via a Promise chain and add a regression test that fires 8 concurrent calls and asserts exactly one plaintext is emitted and validates. - doc/api/http_api.md now documents createPad returning deletionToken and deletePad accepting the optional deletionToken parameter. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/api/http_api.md | 24 ++++++++++++++--- src/node/db/PadDeletionManager.ts | 27 ++++++++++++++----- src/tests/backend/specs/padDeletionManager.ts | 16 +++++++++++ 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/doc/api/http_api.md b/doc/api/http_api.md index 2676a898725..60db60e31bb 100644 --- a/doc/api/http_api.md +++ b/doc/api/http_api.md @@ -519,12 +519,20 @@ Group pads are normal pads, but with the name schema GROUPID$PADNAME. A security #### createPad(padID, [text], [authorId]) * API >= 1 * `authorId` in API >= 1.3.0 +* returns `deletionToken` once, since the same release that added `allowPadDeletionByAllUsers` creates a new (non-group) pad. Note that if you need to create a group Pad, you should call **createGroupPad**. You get an error message if you use one of the following characters in the padID: "/", "?", "&" or "#". +`data.deletionToken` is a one-shot recovery token tied to this pad. It is +returned in plaintext on the first call for a given padID and is `null` on +subsequent calls (the token itself is stored on the server as a sha256 hash). +Pass it to **deletePad** (or the socket `PAD_DELETE` message) to delete the +pad without the creator's author cookie. + *Example returns:* -* `{code: 0, message:"ok", data: null}` +* `{code: 0, message:"ok", data: {deletionToken: "…32-char random string…"}}` +* `{code: 0, message:"ok", data: {deletionToken: null}}` — pad already existed * `{code: 1, message:"padID does already exist", data: null}` * `{code: 1, message:"malformed padID: Remove special characters", data: null}` @@ -581,14 +589,24 @@ returns the list of users that are currently editing this pad * `{code: 0, message:"ok", data: {padUsers: [{colorId:"#c1a9d9","name":"username1","timestamp":1345228793126,"id":"a.n4gEeMLsvg12452n"},{"colorId":"#d9a9cd","name":"Hmmm","timestamp":1345228796042,"id":"a.n4gEeMLsvg12452n"}]}}` * `{code: 0, message:"ok", data: {padUsers: []}}` -#### deletePad(padID) +#### deletePad(padID, [deletionToken]) * API >= 1 +* `deletionToken` in the same release as `allowPadDeletionByAllUsers` + +deletes a pad. -deletes a pad +`deletionToken` is the one-shot recovery token returned by `createPad` / +`createGroupPad`. An apikey-authenticated caller can pass any (or no) token +and the call still succeeds — trusted admins bypass the check. An +unauthenticated caller (or a caller that explicitly passes a wrong token) +is rejected with `invalid deletionToken` unless the operator has set +`allowPadDeletionByAllUsers: true` in `settings.json`, in which case the +token is ignored. *Example returns:* * `{code: 0, message:"ok", data: null}` * `{code: 1, message:"padID does not exist", data: null}` +* `{code: 1, message:"invalid deletionToken", data: null}` #### copyPad(sourceID, destinationID[, force=false]) * API >= 1.2.8 diff --git a/src/node/db/PadDeletionManager.ts b/src/node/db/PadDeletionManager.ts index a6df3f54956..e37a240b6da 100644 --- a/src/node/db/PadDeletionManager.ts +++ b/src/node/db/PadDeletionManager.ts @@ -10,14 +10,29 @@ const getDeletionTokenKey = (padId: string) => `pad:${padId}:deletionToken`; const hashDeletionToken = (deletionToken: string) => crypto.createHash('sha256').update(deletionToken, 'utf8').digest(); +// Per-pad serialisation for token creation. Without this, two concurrent +// `createDeletionTokenIfAbsent()` calls for the same pad can both observe +// an empty slot, both write a hash, and leave the earlier caller holding a +// plaintext token that no longer validates. The chain is cleaned up once the +// outstanding call resolves so this map doesn't grow unbounded. +const inflightCreate: Map> = new Map(); + exports.createDeletionTokenIfAbsent = async (padId: string): Promise => { - if (await DB.db.get(getDeletionTokenKey(padId)) != null) return null; - const deletionToken = randomString(32); - await DB.db.set(getDeletionTokenKey(padId), { - createdAt: Date.now(), - hash: hashDeletionToken(deletionToken).toString('hex'), + const prior = inflightCreate.get(padId); + const next = (prior || Promise.resolve()).then(async () => { + if (await DB.db.get(getDeletionTokenKey(padId)) != null) return null; + const deletionToken = randomString(32); + await DB.db.set(getDeletionTokenKey(padId), { + createdAt: Date.now(), + hash: hashDeletionToken(deletionToken).toString('hex'), + }); + return deletionToken; + }); + const tracked = next.finally(() => { + if (inflightCreate.get(padId) === tracked) inflightCreate.delete(padId); }); - return deletionToken; + inflightCreate.set(padId, tracked); + return next; }; exports.isValidDeletionToken = async (padId: string, deletionToken: string | null | undefined) => { diff --git a/src/tests/backend/specs/padDeletionManager.ts b/src/tests/backend/specs/padDeletionManager.ts index f5b3932fab4..d6cbbe04b10 100644 --- a/src/tests/backend/specs/padDeletionManager.ts +++ b/src/tests/backend/specs/padDeletionManager.ts @@ -37,6 +37,22 @@ describe(__filename, function () { await padDeletionManager.removeDeletionToken(a); await padDeletionManager.removeDeletionToken(b); }); + + it('concurrent calls for the same pad produce a single validating token', + async function () { + const padId = uniqueId(); + const results = await Promise.all( + Array.from({length: 8}, + () => padDeletionManager.createDeletionTokenIfAbsent(padId))); + // Exactly one caller should get the plaintext token; the rest see null. + const nonNull = results.filter((r) => r != null); + assert.equal(nonNull.length, 1, `results: ${JSON.stringify(results)}`); + const [token] = nonNull; + assert.equal( + await padDeletionManager.isValidDeletionToken(padId, token), true, + 'the one token returned must validate against the stored hash'); + await padDeletionManager.removeDeletionToken(padId); + }); }); describe('isValidDeletionToken', function () { From d3bcf7a52e4efb90e33dfce36690c42bd8ac204c Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 12:00:04 +0100 Subject: [PATCH 18/19] fix(gdpr): always render delete-with-token in settings popup The rebase onto develop placed the delete-pad-with-token details inside the pad-settings-section conditional, which is only rendered when enablePadWideSettings is true AND the section is toggled visible. Second-device recovery (typing the captured token on a fresh browser) must work without pad-wide settings enabled, so move the details out to sit alongside the existing pad_deletion_token.spec.ts expectations. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/templates/pad.html | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/templates/pad.html b/src/templates/pad.html index 97badacdf8e..79e59bf12b1 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -251,15 +251,15 @@

<% e.end_block(); %> -
- Delete with token - - - -
<% } %> +
+ Delete with token + + + +

About

Powered by Etherpad From 703c5a3b035ed3bcc4a4cf19f63a08b8cf9a3a60 Mon Sep 17 00:00:00 2001 From: John McLear Date: Fri, 1 May 2026 10:27:05 +0100 Subject: [PATCH 19/19] fix(gdpr): require valid token when supplied, gate on auth, harden a11y/i18n - PadMessageHandler: a supplied deletion token must validate; do not fall back to the creator-cookie path when the token is wrong (was deleting the pad anyway when the creator pasted a wrong token into the field). - Skip token issuance + UI when requireAuthentication is on (creator identity is stable, recovery token is redundant noise). - Server emits messageKey instead of hardcoded English; both shout handlers (inline alert and global gritter) localize via html10n. - Suppress the global "Admin message" gritter for pad.deletionToken.* shouts to avoid the "Admin message: undefined" duplicate. - Token-modal a11y: role=dialog, aria-modal, aria-labelledby/describedby, visually-hidden label on the token input, aria-live on Copy, focus to the token input on open and restore on dismiss. - Style the "Delete Pad with Token" disclosure to match the Delete pad button; align the Copy/value row; pad the disclosure label. Tests: Playwright now covers the creator-with-wrong-token path, asserts no "Admin message" / "undefined" gritter on denial; backend API test covers requireAuthentication suppressing the token. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/locales/en.json | 4 ++- src/node/db/API.ts | 8 ++++- src/node/handler/PadMessageHandler.ts | 28 +++++++++++---- src/static/js/pad.ts | 25 ++++++++++---- src/static/js/pad_editor.ts | 10 ++++-- .../skins/colibris/src/components/popup.css | 29 ++++++++++++++-- src/templates/pad.html | 15 +++++--- src/tests/backend/specs/api/deletePad.ts | 14 +++++++- .../specs/pad_deletion_token.spec.ts | 34 ++++++++++++++++++- 9 files changed, 142 insertions(+), 25 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 7a11dc75171..cc0a44a7f34 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -106,9 +106,11 @@ "pad.deletionToken.copy": "Copy", "pad.deletionToken.copied": "Copied", "pad.deletionToken.acknowledge": "I've saved it", - "pad.deletionToken.deleteWithToken": "Delete with token", + "pad.deletionToken.deleteWithToken": "Delete Pad with Token", "pad.deletionToken.tokenFieldLabel": "Pad deletion token", + "pad.deletionToken.tokenValueLabel": "Your pad deletion token (read-only)", "pad.deletionToken.invalid": "That token is not valid for this pad.", + "pad.deletionToken.notCreator": "You are not the creator of this pad, so you cannot delete it.", "pad.settings.about": "About", "pad.settings.poweredBy": "Powered by", diff --git a/src/node/db/API.ts b/src/node/db/API.ts index b88a708fb58..4de43f52e29 100644 --- a/src/node/db/API.ts +++ b/src/node/db/API.ts @@ -520,7 +520,13 @@ exports.createPad = async (padID: string, text: string, authorId = '') => { // create pad await getPadSafe(padID, false, text, authorId); - return {deletionToken: await padDeletionManager.createDeletionTokenIfAbsent(padID)}; + // When requireAuthentication is on, every creator has a stable identity, so + // the cookie/identity path covers recovery and the one-time token is just + // an extra surface to leak. + const deletionToken = settings.requireAuthentication + ? null + : await padDeletionManager.createDeletionTokenIfAbsent(padID); + return {deletionToken}; }; /** diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index 9f77fdd6be3..f2e96d2f91f 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -265,22 +265,34 @@ const handlePadDelete = async (socket: any, padDeleteMessage: PadDeleteMessage) const retrievedPad = await padManager.getPad(padId); const firstContributor = await retrievedPad.getRevisionAuthor(0); const isCreator = session.author === firstContributor; - const tokenOk = !isCreator && await padDeletionManager.isValidDeletionToken( - padId, padDeleteMessage.data.deletionToken); - const flagOk = !isCreator && !tokenOk && settings.allowPadDeletionByAllUsers; - - if (isCreator || tokenOk || flagOk) { + const suppliedToken = padDeleteMessage.data.deletionToken; + const tokenSupplied = typeof suppliedToken === 'string' && suppliedToken !== ''; + const tokenOk = tokenSupplied && + await padDeletionManager.isValidDeletionToken(padId, suppliedToken); + // When a token is supplied it must validate. We deliberately do NOT fall + // back to the creator-cookie path, otherwise a creator pasting a wrong + // recovery token into the disclosure field would still succeed — masking a + // typo and contradicting the UI. + const creatorOk = !tokenSupplied && isCreator; + const flagOk = !tokenSupplied && !isCreator && settings.allowPadDeletionByAllUsers; + + if (creatorOk || tokenOk || flagOk) { await retrievedPad.remove(); return; } + // tokenSupplied-but-invalid is a different user-facing message from + // not-the-creator. The client localizes via the l10n key. + const messageKey = tokenSupplied + ? 'pad.deletionToken.invalid' + : 'pad.deletionToken.notCreator'; socket.emit('shout', { type: 'COLLABROOM', data: { type: 'shoutMessage', payload: { message: { - message: 'You are not the creator of this pad, so you cannot delete it', + messageKey, sticky: false, }, timestamp: Date.now(), @@ -1102,7 +1114,9 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { // once. Readonly sessions never see it. const isCreator = !sessionInfo.readonly && sessionInfo.author === await pad.getRevisionAuthor(0); - const padDeletionToken = isCreator + // Skip token issuance when requireAuthentication is on: every creator has a + // stable identity so the cookie/identity path is sufficient. + const padDeletionToken = isCreator && !settings.requireAuthentication ? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId) : null; diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index 4919d175a53..5cc28b5eaee 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -245,7 +245,11 @@ const showDeletionTokenModalIfPresent = () => { if ($modal.length === 0) return; $input.val(token); + const previouslyFocused = document.activeElement as HTMLElement | null; $modal.prop('hidden', false).addClass('popup-show'); + // Focus the token input so screen readers announce the dialog body and the + // user lands on the value they need to copy. + setTimeout(() => ($input[0] as HTMLInputElement)?.focus(), 0); $copy.off('click.gdpr').on('click.gdpr', async () => { try { @@ -261,6 +265,9 @@ const showDeletionTokenModalIfPresent = () => { $input.val(''); $modal.prop('hidden', true).removeClass('popup-show'); (window as any).clientVars.padDeletionToken = null; + if (previouslyFocused && document.body.contains(previouslyFocused)) { + previouslyFocused.focus(); + } }); }; @@ -368,14 +375,20 @@ const handshake = async () => { socket.on('shout', (obj) => { if(obj.type === "COLLABROOM") { - let date = new Date(obj.data.payload.timestamp); + const payload = obj.data.payload; + const msgObj = payload?.message || {}; + // Pad-deletion denial shouts are surfaced inline by pad_editor.ts as an + // alert tied to the delete action; suppress the global "Admin message" + // gritter so the user doesn't see a confusing duplicate. + if (typeof msgObj.messageKey === 'string' + && msgObj.messageKey.startsWith('pad.deletionToken.')) return; + const text = msgObj.messageKey ? html10n.get(msgObj.messageKey) : msgObj.message; + if (!text) return; + const date = new Date(payload.timestamp); $.gritter.add({ - // (string | mandatory) the heading of the notification title: 'Admin message', - // (string | mandatory) the text inside the notification - text: '[' + date.toLocaleTimeString() + ']: ' + obj.data.payload.message.message, - // (bool | optional) if you want it to fade out on its own or just sit there - sticky: obj.data.payload.message.sticky + text: '[' + date.toLocaleTimeString() + ']: ' + text, + sticky: msgObj.sticky }); } }) diff --git a/src/static/js/pad_editor.ts b/src/static/js/pad_editor.ts index a6b8c73e5dc..a828bb38d6a 100644 --- a/src/static/js/pad_editor.ts +++ b/src/static/js/pad_editor.ts @@ -152,7 +152,10 @@ const padeditor = (() => { }); pad.socket.on('shout', (data: any) => { handled = true; - const msg = data?.data?.payload?.message?.message; + const payload = data?.data?.payload?.message; + const msg = payload?.messageKey + ? html10n.get(payload.messageKey) + : payload?.message; if (msg) window.alert(msg); }); pad.collabClient.sendMessage({ @@ -182,7 +185,10 @@ const padeditor = (() => { // message instead of deleting. Listen for it and show the error. pad.socket.on('shout', (data: any) => { handled = true; - const msg = data?.data?.payload?.message?.message; + const payload = data?.data?.payload?.message; + const msg = payload?.messageKey + ? html10n.get(payload.messageKey) + : payload?.message; if (msg) window.alert(msg); }); pad.collabClient.sendMessage({type: 'PAD_DELETE', data:{padId: pad.getPadId()}}); diff --git a/src/static/skins/colibris/src/components/popup.css b/src/static/skins/colibris/src/components/popup.css index 3940c8ccdb8..9d61d51764b 100644 --- a/src/static/skins/colibris/src/components/popup.css +++ b/src/static/skins/colibris/src/components/popup.css @@ -125,6 +125,11 @@ display: flex; gap: 0.5rem; margin: 1rem 0; + align-items: center; +} + +#deletiontoken-modal #deletiontoken-copy { + padding-top: 10px; } #deletiontoken-modal #deletiontoken-value { @@ -139,9 +144,29 @@ } #delete-pad-with-token summary { + display: inline-block; + list-style: none; cursor: pointer; - color: #666; - font-size: 0.9rem; + padding: 5px 20px; + border-radius: 4px; + font-weight: bold; + background: #d1242f; + color: #fff; +} + +#delete-pad-with-token summary::-webkit-details-marker { + display: none; +} + +#delete-pad-with-token summary:hover { + background: #b71c26; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 1px 2px rgba(0,0,0,0.12); + transform: translateY(-1px); +} + +#delete-pad-with-token label { + display: block; + padding-top: 12px; } #delete-pad-with-token input { diff --git a/src/templates/pad.html b/src/templates/pad.html index 79e59bf12b1..b6e38d63675 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -253,6 +253,7 @@

<% } %> + <% if (!settings.requireAuthentication) { %>
Delete with token @@ -260,6 +261,7 @@

+ <% } %>

About

Powered by Etherpad @@ -269,14 +271,19 @@

About

-