mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-05-05 04:06:37 +02:00
* docs: PR4 GDPR privacy banner design spec
* docs: PR4 GDPR privacy banner implementation plan
* feat(gdpr): typed privacyBanner setting block + public getter exposure
* feat(gdpr): send privacyBanner config to the browser via clientVars
* feat(gdpr): privacy banner DOM (hidden by default)
* feat(gdpr): render privacy banner on pad load when enabled
* style(gdpr): privacy banner layout
* test+fix(gdpr): privacy banner Playwright + hidden-attr CSS override
* docs(gdpr): privacyBanner configuration section
* fix(gdpr): reject unsafe learnMoreUrl schemes
Qodo review: showPrivacyBannerIfEnabled assigned config.learnMoreUrl
directly to <a href>, so a misconfigured settings.privacyBanner.
learnMoreUrl of `javascript:alert(1)` or `data:…<script>…` would run
script on click. Validate via URL parsing and allow only http(s) /
mailto; everything else yields no link. Playwright regression guards
the four cases (javascript, data, https, mailto).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(privacy-banner): drop unneeded !important on [hidden] rule
Class+attribute selector already outranks `.privacy-banner { display: flex }`
on specificity (0,2,0 vs 0,1,0), so `!important` was redundant. Adds a
comment explaining why so a future reader doesn't put it back.
Per Sam's review on #7549.
* refactor(privacy-banner): render as a persistent gritter, not custom DOM
Drops the bespoke #privacy-banner template + ~50 lines of popup.css and
delegates to $.gritter.add({sticky: true, position: 'bottom'}). The
notice now matches every other gritter on the pad (theme variables,
shadow, animation, (X) close), sits in the bottom corner instead of
above the editor, and inherits dark-mode handling for free.
The two dismissal modes survive intact:
- dismissible: gritter closes on (X); before_close persists a flag
in localStorage so the notice is suppressed on subsequent loads.
- sticky: closes for the current session only; never persists; the
next pad load shows it again.
learnMoreUrl still goes through the same safeUrl() filter so a
javascript:/data:/vbscript: URL can't smuggle a script handler into the
anchor (Qodo's review concern remains addressed).
Tests: src/tests/frontend-new/specs/privacy_banner.spec.ts now drives
the real showPrivacyBannerIfEnabled via a __etherpad_privacyBanner__
test hook and asserts against the rendered gritter, instead of the
previous tests that mutated DOM by hand and never exercised the
function under test. Coverage adds: enabled=false short-circuit,
dismissible-flag-respected on subsequent show, sticky-ignores-flag,
sticky-close-does-not-persist, javascript: rejection, data: rejection,
and mailto: allow-list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(privacy-banner): noreferrer + validate dismissal (Qodo)
Two follow-ups from Qodo's review on #7549:
1. The Learn-more link now sets `rel="noreferrer noopener"` (was just
`noopener`). Without `noreferrer` the browser sends the pad URL as a
Referer to the operator-configured external policy site, which leaks
pad identifiers to a third party. Matches the rel pattern already
used by pad_utils.ts.
2. `privacyBanner.dismissal` is now validated in reloadSettings(): an
unknown value falls back to 'dismissible' with a `logger.warn`, in
the same shape as the existing ipLogging validation a few lines up.
The client also guards defensively (treats anything other than the
exact string 'sticky' as 'dismissible') so that hot-reload paths
that skip the server validator can't silently degrade a typo'd
'sticky' into "no close button persisted, no localStorage suppression".
Test added: spec asserts the rel attribute, and a new test exercises
the dismissal fallback (sets dismissal:'wat', asserts the gritter is
shown, the (X) closes it, and the dismissal flag is persisted — i.e.
the unknown value is treated like 'dismissible').
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(privacy-banner): gate test hook on webdriver, align doc with sticky behavior
Two follow-ups from Qodo's second review on #7549.
Rule violation: __etherpad_privacyBanner__ was published on every pad
load even when privacyBanner.enabled was false, so the disabled-by-
default feature still added an observable global. Gate the assignment
on `navigator.webdriver` — Playwright/ChromeDriver/Selenium set this
to true; production browsers do not — so the hook is only present for
tests and the disabled path is genuinely zero-side-effect.
Bug 3 (sticky still closable): doc/privacy.md previously claimed
`dismissal: "sticky"` removes the close button, but the gritter
implementation always renders (X). Aligning the doc with reality —
sticky now means "shows on every load, but closable for the session"
— rather than adding bespoke CSS to a vanilla gritter (matches the
"don't style it differently than other gritter messages" preference
that drove the gritter migration in 906e145).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(privacy-banner): allow-list keys before sending to clientVars (Qodo)
storeSettings() merges nested objects with _.defaults() and preserves
unknown nested keys, and TypeScript's Pick<> doesn't strip at runtime.
The previous wire path forwarded settings.privacyBanner by reference
into both clientVars and getPublicSettings(), so any extra keys an
operator typed (or pasted) under privacyBanner — credentials, internal
notes, anything — would have shipped to every browser on every pad
load.
Adds getPublicPrivacyBanner() in Settings.ts that returns a literal
with only {enabled, title, body, learnMoreUrl, dismissal}, and uses it
from both leak sites (PadMessageHandler.ts clientVars and
getPublicSettings()). Single source of truth for the wire shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a0b85dd8b3
commit
487842006c
@ -59,3 +59,38 @@ See
|
||||
[`docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md`](https://github.com/ether/etherpad/blob/develop/docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md)
|
||||
for the deletion-token mechanism. Full author erasure is tracked as a
|
||||
follow-up in [ether/etherpad#6701](https://github.com/ether/etherpad/issues/6701).
|
||||
|
||||
## Privacy banner (optional)
|
||||
|
||||
The `privacyBanner` block in `settings.json` lets you display a short
|
||||
notice to every pad user — data-processing statement, retention
|
||||
policy, contact for erasure requests, etc.
|
||||
|
||||
```jsonc
|
||||
"privacyBanner": {
|
||||
"enabled": true,
|
||||
"title": "Privacy notice",
|
||||
"body": "This instance stores pad content for 90 days. Contact privacy@example.com to request erasure.",
|
||||
"learnMoreUrl": "https://example.com/privacy",
|
||||
"dismissal": "dismissible"
|
||||
}
|
||||
```
|
||||
|
||||
The banner is rendered as a persistent gritter notification at the
|
||||
bottom of the page (it inherits the same look as every other gritter
|
||||
on the pad — no custom skin needed). The body is plain text (HTML is
|
||||
escaped); each line becomes its own paragraph.
|
||||
|
||||
`dismissal` controls how the close (×) is handled:
|
||||
|
||||
- `"dismissible"` (default) — when the user closes the gritter, the
|
||||
choice is persisted in `localStorage` per origin and the banner is
|
||||
not shown again on subsequent pad loads.
|
||||
- `"sticky"` — closing the gritter only hides it for the current
|
||||
session; the next pad load shows it again. (The close control is
|
||||
not removed; for an operator-enforced non-closable notice, render
|
||||
the policy out-of-band — e.g., a skin override or a reverse-proxy
|
||||
ribbon.)
|
||||
|
||||
Unknown `dismissal` values are coerced to `"dismissible"` with a
|
||||
`logger.warn` at settings load.
|
||||
|
||||
595
docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md
Normal file
595
docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md
Normal file
@ -0,0 +1,595 @@
|
||||
# GDPR PR4 — Privacy Banner 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:** Let operators configure a short privacy notice via `settings.json` that shows as a dismissible (or sticky) banner on pad load. Default off, opt-in.
|
||||
|
||||
**Architecture:** A new `privacyBanner` block in `SettingsType`; `getPublicSettings()` exposes a trimmed version to the client via `clientVars.privacyBanner`. `pad.html` has a hidden `<div id="privacy-banner">`. A new `privacy_banner.ts` module, called from `pad.ts` post-init, fills the banner from `clientVars` using `textContent` (XSS-safe), hooks a close button that persists dismissal in `localStorage` per origin.
|
||||
|
||||
**Tech Stack:** TypeScript, EJS templates, colibris CSS skin, Playwright for frontend tests.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Created:**
|
||||
- `src/static/js/privacy_banner.ts` — fills the banner from `clientVars`
|
||||
- `src/tests/frontend-new/specs/privacy_banner.spec.ts` — Playwright coverage
|
||||
|
||||
**Modified:**
|
||||
- `settings.json.template`, `settings.json.docker` — add the `privacyBanner` block
|
||||
- `src/node/utils/Settings.ts` — typed field + default + expose via `getPublicSettings()`
|
||||
- `src/node/handler/PadMessageHandler.ts` — include `privacyBanner` in `clientVars`
|
||||
- `src/static/js/types/SocketIOMessage.ts` — add `privacyBanner` to `ClientVarPayload`
|
||||
- `src/templates/pad.html` — hidden banner markup
|
||||
- `src/static/js/pad.ts` — import + call `showPrivacyBannerIfEnabled` after `postAceInit`
|
||||
- `src/static/skins/colibris/src/components/popup.css` (or appropriate skin file) — styling
|
||||
- `doc/privacy.md` — one section describing the banner settings
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Typed settings block + default + getPublicSettings()
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/node/utils/Settings.ts`
|
||||
- Modify: `settings.json.template`
|
||||
- Modify: `settings.json.docker`
|
||||
|
||||
- [ ] **Step 1: Extend `SettingsType`**
|
||||
|
||||
Add to the interface (near `enableDarkMode`):
|
||||
|
||||
```typescript
|
||||
privacyBanner: {
|
||||
enabled: boolean,
|
||||
title: string,
|
||||
body: string,
|
||||
learnMoreUrl: string | null,
|
||||
dismissal: 'dismissible' | 'sticky',
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend the default `settings` object**
|
||||
|
||||
Add next to `enableDarkMode: true`:
|
||||
|
||||
```typescript
|
||||
privacyBanner: {
|
||||
enabled: false,
|
||||
title: 'Privacy notice',
|
||||
body: 'This instance processes pad content on our servers. ' +
|
||||
'See the linked policy for retention and how to request erasure.',
|
||||
learnMoreUrl: null,
|
||||
dismissal: 'dismissible',
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Expose via `getPublicSettings()`**
|
||||
|
||||
Locate the `getPublicSettings` function (around line 658 in Settings.ts). Add a `privacyBanner` key to both the returned object and the `Pick<>` type right above it:
|
||||
|
||||
```typescript
|
||||
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "privacyBanner">,
|
||||
```
|
||||
|
||||
And in the returned object:
|
||||
|
||||
```typescript
|
||||
privacyBanner: settings.privacyBanner,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: `settings.json.template` block**
|
||||
|
||||
Append (near the `enableDarkMode` block):
|
||||
|
||||
```jsonc
|
||||
/*
|
||||
* Optional privacy banner shown once the pad loads. Disabled by default.
|
||||
*
|
||||
* enabled — toggle the feature
|
||||
* title — plain-text heading (HTML is escaped)
|
||||
* body — plain-text body; blank lines become paragraph breaks
|
||||
* learnMoreUrl — optional URL rendered as a "Learn more" link
|
||||
* dismissal — "dismissible" (close button, stored in localStorage)
|
||||
* or "sticky" (always shown, no close button)
|
||||
*/
|
||||
"privacyBanner": {
|
||||
"enabled": false,
|
||||
"title": "Privacy notice",
|
||||
"body": "This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.",
|
||||
"learnMoreUrl": null,
|
||||
"dismissal": "dismissible"
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `settings.json.docker` mirror**
|
||||
|
||||
```jsonc
|
||||
"privacyBanner": {
|
||||
"enabled": "${PRIVACY_BANNER_ENABLED:false}",
|
||||
"title": "${PRIVACY_BANNER_TITLE:Privacy notice}",
|
||||
"body": "${PRIVACY_BANNER_BODY:This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.}",
|
||||
"learnMoreUrl": "${PRIVACY_BANNER_LEARN_MORE_URL:null}",
|
||||
"dismissal": "${PRIVACY_BANNER_DISMISSAL:dismissible}"
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Type check + commit**
|
||||
|
||||
```bash
|
||||
pnpm --filter ep_etherpad-lite run ts-check
|
||||
git add src/node/utils/Settings.ts settings.json.template settings.json.docker
|
||||
git commit -m "feat(gdpr): typed privacyBanner setting block + public getter exposure"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Wire `privacyBanner` through `clientVars`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/node/handler/PadMessageHandler.ts`
|
||||
- Modify: `src/static/js/types/SocketIOMessage.ts`
|
||||
|
||||
- [ ] **Step 1: Extend `ClientVarPayload`**
|
||||
|
||||
Add to the type (beside `padOptions`):
|
||||
|
||||
```typescript
|
||||
privacyBanner?: {
|
||||
enabled: boolean,
|
||||
title: string,
|
||||
body: string,
|
||||
learnMoreUrl: string | null,
|
||||
dismissal: 'dismissible' | 'sticky',
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Include it in the `clientVars` literal**
|
||||
|
||||
In `PadMessageHandler.handleClientReady` find the `clientVars` object literal (around line 1036) and add:
|
||||
|
||||
```typescript
|
||||
privacyBanner: settings.privacyBanner,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Type check + commit**
|
||||
|
||||
```bash
|
||||
pnpm --filter ep_etherpad-lite run ts-check
|
||||
git add src/node/handler/PadMessageHandler.ts src/static/js/types/SocketIOMessage.ts
|
||||
git commit -m "feat(gdpr): send privacyBanner config to the browser via clientVars"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Template markup
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/templates/pad.html`
|
||||
|
||||
- [ ] **Step 1: Add the hidden banner before `<div id="editorcontainerbox">`**
|
||||
|
||||
Read `src/templates/pad.html` to find the right spot (below the toolbar, above the editor container). Insert:
|
||||
|
||||
```html
|
||||
<div id="privacy-banner" class="privacy-banner" hidden>
|
||||
<div class="privacy-banner-content">
|
||||
<strong class="privacy-banner-title"></strong>
|
||||
<div class="privacy-banner-body"></div>
|
||||
<div class="privacy-banner-link"></div>
|
||||
</div>
|
||||
<button id="privacy-banner-close" type="button"
|
||||
class="privacy-banner-close" aria-label="Dismiss" hidden>×</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/templates/pad.html
|
||||
git commit -m "feat(gdpr): privacy banner DOM (hidden by default)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `privacy_banner.ts` + wire into `pad.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/static/js/privacy_banner.ts`
|
||||
- Modify: `src/static/js/pad.ts` — call after `postAceInit`
|
||||
|
||||
- [ ] **Step 1: Create the module**
|
||||
|
||||
```typescript
|
||||
// src/static/js/privacy_banner.ts
|
||||
'use strict';
|
||||
|
||||
type BannerConfig = {
|
||||
enabled: boolean,
|
||||
title: string,
|
||||
body: string,
|
||||
learnMoreUrl: string | null,
|
||||
dismissal: 'dismissible' | 'sticky',
|
||||
};
|
||||
|
||||
const storageKey = (url: string): string => {
|
||||
try {
|
||||
return `etherpad.privacyBanner.dismissed:${new URL(url).origin}`;
|
||||
} catch (_e) {
|
||||
return 'etherpad.privacyBanner.dismissed';
|
||||
}
|
||||
};
|
||||
|
||||
export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => {
|
||||
if (!config || !config.enabled) return;
|
||||
const banner = document.getElementById('privacy-banner');
|
||||
if (banner == null) return;
|
||||
|
||||
if (config.dismissal === 'dismissible') {
|
||||
try {
|
||||
if (localStorage.getItem(storageKey(location.href)) === '1') return;
|
||||
} catch (_e) { /* proceed without persistence */ }
|
||||
}
|
||||
|
||||
const titleEl = banner.querySelector('.privacy-banner-title') as HTMLElement | null;
|
||||
if (titleEl) titleEl.textContent = config.title || '';
|
||||
|
||||
const bodyEl = banner.querySelector('.privacy-banner-body') as HTMLElement | null;
|
||||
if (bodyEl) {
|
||||
bodyEl.textContent = '';
|
||||
for (const line of (config.body || '').split(/\r?\n/)) {
|
||||
const p = document.createElement('p');
|
||||
p.textContent = line;
|
||||
bodyEl.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
const linkEl = banner.querySelector('.privacy-banner-link') as HTMLElement | null;
|
||||
if (linkEl) {
|
||||
linkEl.replaceChildren();
|
||||
if (config.learnMoreUrl) {
|
||||
const a = document.createElement('a');
|
||||
a.href = config.learnMoreUrl;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.textContent = 'Learn more';
|
||||
linkEl.appendChild(a);
|
||||
}
|
||||
}
|
||||
|
||||
const closeBtn = banner.querySelector('#privacy-banner-close') as HTMLButtonElement | null;
|
||||
if (closeBtn) {
|
||||
if (config.dismissal === 'dismissible') {
|
||||
closeBtn.hidden = false;
|
||||
closeBtn.onclick = () => {
|
||||
banner.hidden = true;
|
||||
try {
|
||||
localStorage.setItem(storageKey(location.href), '1');
|
||||
} catch (_e) { /* best-effort */ }
|
||||
};
|
||||
} else {
|
||||
closeBtn.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
banner.hidden = false;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Call it from `pad.ts`**
|
||||
|
||||
In `src/static/js/pad.ts`, inside `postAceInit` (just after the
|
||||
existing `showDeletionTokenModalIfPresent()` / modal call on the
|
||||
post-PR1 branch, or just before `hooks.aCallAll('postAceInit', …)`),
|
||||
add an import at the top:
|
||||
|
||||
```typescript
|
||||
import {showPrivacyBannerIfEnabled} from './privacy_banner';
|
||||
```
|
||||
|
||||
And a call inside `postAceInit`:
|
||||
|
||||
```typescript
|
||||
showPrivacyBannerIfEnabled((clientVars as any).privacyBanner);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Type check + commit**
|
||||
|
||||
```bash
|
||||
pnpm --filter ep_etherpad-lite run ts-check
|
||||
git add src/static/js/privacy_banner.ts src/static/js/pad.ts
|
||||
git commit -m "feat(gdpr): render privacy banner on pad load when enabled"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Skin styling
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/static/skins/colibris/src/components/popup.css` (or an adjacent components file)
|
||||
|
||||
- [ ] **Step 1: Append minimal styling**
|
||||
|
||||
```css
|
||||
.privacy-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin: 0.5rem 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #fff7d6;
|
||||
border: 1px solid #e0c97a;
|
||||
border-radius: 4px;
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.privacy-banner .privacy-banner-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.privacy-banner .privacy-banner-title {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.privacy-banner .privacy-banner-body p {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.privacy-banner .privacy-banner-link a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.privacy-banner .privacy-banner-close {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/static/skins/colibris/src/components/popup.css
|
||||
git commit -m "style(gdpr): privacy banner layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Playwright coverage
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/frontend-new/specs/privacy_banner.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Write the spec**
|
||||
|
||||
```typescript
|
||||
import {expect, test, Page} from '@playwright/test';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
|
||||
const freshPad = 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;
|
||||
};
|
||||
|
||||
// The server's `settings.privacyBanner` is swapped at runtime via page.evaluate
|
||||
// on the clientVars object + manual reveal so the test is fully self-contained.
|
||||
// Operators setting the live setting is covered by the settings unit test.
|
||||
const forceBanner = async (page: Page, config: any) => {
|
||||
await page.evaluate((cfg) => {
|
||||
(window as any).clientVars.privacyBanner = cfg;
|
||||
const mod = require('../../../src/static/js/privacy_banner');
|
||||
mod.showPrivacyBannerIfEnabled(cfg);
|
||||
}, config);
|
||||
};
|
||||
|
||||
test.describe('privacy banner', () => {
|
||||
test.beforeEach(async ({context}) => {
|
||||
await context.clearCookies();
|
||||
});
|
||||
|
||||
test('disabled by default — banner stays hidden', async ({page}) => {
|
||||
await freshPad(page);
|
||||
await expect(page.locator('#privacy-banner')).toBeHidden();
|
||||
});
|
||||
|
||||
test('enabled + sticky — banner visible, close button hidden',
|
||||
async ({page}) => {
|
||||
await freshPad(page);
|
||||
await page.evaluate(() => {
|
||||
const banner = document.getElementById('privacy-banner')!;
|
||||
banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy';
|
||||
const body = banner.querySelector('.privacy-banner-body')!;
|
||||
body.textContent = '';
|
||||
const p = document.createElement('p');
|
||||
p.textContent = 'Body text';
|
||||
body.appendChild(p);
|
||||
(banner.querySelector('#privacy-banner-close') as HTMLElement).hidden = true;
|
||||
banner.hidden = false;
|
||||
});
|
||||
await expect(page.locator('#privacy-banner')).toBeVisible();
|
||||
await expect(page.locator('#privacy-banner-close')).toBeHidden();
|
||||
});
|
||||
|
||||
test('dismissible — close button hides and persists in localStorage',
|
||||
async ({page}) => {
|
||||
const padId = await freshPad(page);
|
||||
await page.evaluate(() => {
|
||||
const banner = document.getElementById('privacy-banner')!;
|
||||
banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy';
|
||||
const body = banner.querySelector('.privacy-banner-body')!;
|
||||
body.textContent = '';
|
||||
const p = document.createElement('p');
|
||||
p.textContent = 'Body text';
|
||||
body.appendChild(p);
|
||||
const close = banner.querySelector('#privacy-banner-close') as HTMLButtonElement;
|
||||
close.hidden = false;
|
||||
close.onclick = () => {
|
||||
banner.hidden = true;
|
||||
localStorage.setItem(
|
||||
`etherpad.privacyBanner.dismissed:${location.origin}`, '1');
|
||||
};
|
||||
banner.hidden = false;
|
||||
});
|
||||
await page.locator('#privacy-banner-close').click();
|
||||
await expect(page.locator('#privacy-banner')).toBeHidden();
|
||||
|
||||
const flag = await page.evaluate(
|
||||
() => localStorage.getItem(
|
||||
`etherpad.privacyBanner.dismissed:${location.origin}`));
|
||||
expect(flag).toBe('1');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Restart the test server and run**
|
||||
|
||||
```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 10
|
||||
cd src && NODE_ENV=production npx playwright test privacy_banner --project=chromium
|
||||
```
|
||||
|
||||
Expected: 3 tests pass.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/frontend-new/specs/privacy_banner.spec.ts
|
||||
git commit -m "test(gdpr): Playwright coverage for privacy banner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `doc/privacy.md` (created in PR2 #7547 — may not be on this branch yet. If missing, create a minimal stub.)
|
||||
|
||||
- [ ] **Step 1: Check if `doc/privacy.md` exists; if not, create a stub**
|
||||
|
||||
Run: `ls doc/privacy.md`
|
||||
|
||||
If missing, create a minimal file so the banner doc has a home:
|
||||
|
||||
```markdown
|
||||
# Privacy
|
||||
|
||||
See [cookies.md](cookies.md) for the cookie list and the GDPR work
|
||||
tracked in [ether/etherpad#6701](https://github.com/ether/etherpad/issues/6701).
|
||||
|
||||
## Privacy banner (optional)
|
||||
|
||||
(content added by this PR — see next step)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Append the banner section**
|
||||
|
||||
Append:
|
||||
|
||||
```markdown
|
||||
## Privacy banner (optional)
|
||||
|
||||
The `privacyBanner` block in `settings.json` lets you display a short
|
||||
notice to every pad user — data-processing statement, retention policy,
|
||||
contact for erasure requests, etc.
|
||||
|
||||
```jsonc
|
||||
"privacyBanner": {
|
||||
"enabled": true,
|
||||
"title": "Privacy notice",
|
||||
"body": "This instance stores pad content for 90 days. Contact privacy@example.com to request erasure.",
|
||||
"learnMoreUrl": "https://example.com/privacy",
|
||||
"dismissal": "dismissible"
|
||||
}
|
||||
```
|
||||
|
||||
The banner is rendered from plain text (HTML is escaped) with one
|
||||
paragraph per line. With `dismissal: "dismissible"` the user can close
|
||||
the banner and the choice is remembered in `localStorage` per origin.
|
||||
`dismissal: "sticky"` removes the close button.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add doc/privacy.md
|
||||
git commit -m "docs(gdpr): privacyBanner configuration section"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Verify, push, open PR
|
||||
|
||||
- [ ] **Step 1: Type check**
|
||||
|
||||
Run: `pnpm --filter ep_etherpad-lite run ts-check`
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 2: Run Playwright for the banner + a chat regression**
|
||||
|
||||
```bash
|
||||
cd src && NODE_ENV=production npx playwright test privacy_banner chat.spec --project=chromium
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Step 3: Push + open PR**
|
||||
|
||||
```bash
|
||||
git push origin feat-gdpr-privacy-banner
|
||||
gh pr create --repo ether/etherpad --base develop --head feat-gdpr-privacy-banner \
|
||||
--title "feat(gdpr): configurable privacy banner (PR4 of #6701)" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
- New `privacyBanner` block in `settings.json` (title/body/learnMoreUrl/dismissal); defaults to disabled so existing instances are unchanged.
|
||||
- Banner renders via `clientVars.privacyBanner` after pad init; content is set via `textContent` (HTML escaped).
|
||||
- `dismissible` stores a per-origin flag in `localStorage` so the user only sees it once; `sticky` shows it every load.
|
||||
|
||||
Part of the GDPR work in #6701. PR1 #7546, PR2 #7547, PR3 #7548 already open/merged. PR5 (author erasure) is the last.
|
||||
|
||||
Design: `docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md`
|
||||
Plan: `docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md`
|
||||
|
||||
## Test plan
|
||||
- [x] ts-check
|
||||
- [x] Playwright — disabled / sticky / dismissible
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Monitor CI**
|
||||
|
||||
Run: `gh pr checks <PR-number> --repo ether/etherpad`
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
| Spec section | Task |
|
||||
| --- | --- |
|
||||
| `privacyBanner` settings block | 1 |
|
||||
| `getPublicSettings()` exposure | 1 |
|
||||
| `clientVars.privacyBanner` wiring | 2 |
|
||||
| Template DOM | 3 |
|
||||
| Client JS (textContent, link, close button) | 4 |
|
||||
| Styling | 5 |
|
||||
| Playwright tests | 6 |
|
||||
| Docs | 7 |
|
||||
|
||||
**Placeholders:** none.
|
||||
|
||||
**Type consistency:**
|
||||
- `BannerConfig` shape matches `SettingsType.privacyBanner` (Task 1) exactly (Task 4).
|
||||
- `dismissal: 'dismissible' | 'sticky'` union consistent in Tasks 1, 2, 4.
|
||||
- `clientVars.privacyBanner` optional on the client, always sent from the server — matches `?:` on `ClientVarPayload`.
|
||||
@ -0,0 +1,183 @@
|
||||
# PR4 — GDPR Configurable Privacy Banner
|
||||
|
||||
Fourth of five GDPR PRs (ether/etherpad#6701). Lets instance operators
|
||||
surface a short, localisable privacy notice — data processing statement,
|
||||
retention policy, contact for erasure requests — when a user opens or
|
||||
creates a pad, without writing a plugin.
|
||||
|
||||
## Goals
|
||||
|
||||
- One `settings.json` block defines the banner: whether it's shown, the
|
||||
title, the body, a "learn more" link, and how dismissal works.
|
||||
- Banner renders on every pad load when enabled. The user can dismiss
|
||||
it once per browser (stored in `localStorage`) if the operator
|
||||
chose "dismissible".
|
||||
- Works with the `colibris` skin out of the box, no plugin required.
|
||||
- Disabled by default — instances that don't want a banner see no
|
||||
behaviour change.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Markdown rendering. Body is plain text; HTML escaped at render.
|
||||
- Consent recording / "I consent" persistence. This is informational
|
||||
only — recording consent is a separate compliance regime.
|
||||
- Multi-language. Operators who need l10n can wrap the body in their
|
||||
own plugin-level substitution.
|
||||
- Admin UI for editing the banner. Edits happen in `settings.json`.
|
||||
|
||||
## Design
|
||||
|
||||
### Settings
|
||||
|
||||
```jsonc
|
||||
"privacyBanner": {
|
||||
/*
|
||||
* Master switch. Defaults to false so existing instances are unchanged.
|
||||
*/
|
||||
"enabled": false,
|
||||
/*
|
||||
* Short heading shown in bold. Plain text, HTML is escaped.
|
||||
*/
|
||||
"title": "Privacy notice",
|
||||
/*
|
||||
* Body text. Plain text, HTML is escaped. Newlines become <br>.
|
||||
*/
|
||||
"body": "This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.",
|
||||
/*
|
||||
* Optional URL appended as a "Learn more" link. Omit or set to null
|
||||
* to hide the link.
|
||||
*/
|
||||
"learnMoreUrl": null,
|
||||
/*
|
||||
* One of:
|
||||
* "dismissible" (default) — show a close button; dismissal persists
|
||||
* in localStorage under a per-instance key
|
||||
* "sticky" — no close button; banner shown every load
|
||||
*/
|
||||
"dismissal": "dismissible"
|
||||
}
|
||||
```
|
||||
|
||||
`SettingsType` gains a matching strongly-typed block. The default in
|
||||
code is `{enabled: false, title: '', body: '', learnMoreUrl: null,
|
||||
dismissal: 'dismissible'}`.
|
||||
|
||||
### Server wiring
|
||||
|
||||
- `settings.getPublicSettings()` picks up a trimmed view of the banner:
|
||||
`{enabled, title, body, learnMoreUrl, dismissal}`. Nothing else from
|
||||
`privacyBanner` leaks.
|
||||
- `PadMessageHandler` already sends `settings.getPublicSettings()` via
|
||||
`clientVars.skinName` etc. — add the banner shape to `ClientVarPayload`
|
||||
and include it in the clientVars literal.
|
||||
|
||||
### Template
|
||||
|
||||
- Add `<div id="privacy-banner" hidden>` to `src/templates/pad.html`,
|
||||
styled by the colibris skin. Collapsed by default.
|
||||
- Contents: title `<strong>`, body `<p>` (each line becomes a `<p>` so
|
||||
newlines behave), optional `<a target="_blank" rel="noopener">`,
|
||||
and a `<button id="privacy-banner-close">` that's rendered only if
|
||||
`dismissal === "dismissible"`.
|
||||
- Body text is written via textContent (not innerHTML) to avoid XSS.
|
||||
|
||||
### Client JS
|
||||
|
||||
New `src/static/js/privacy_banner.ts`:
|
||||
|
||||
```typescript
|
||||
'use strict';
|
||||
|
||||
type BannerConfig = {
|
||||
enabled: boolean,
|
||||
title: string,
|
||||
body: string,
|
||||
learnMoreUrl: string | null,
|
||||
dismissal: 'dismissible' | 'sticky',
|
||||
};
|
||||
|
||||
const storageKey = (url: string): string =>
|
||||
`etherpad.privacyBanner.dismissed:${new URL(url).origin}`;
|
||||
|
||||
export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => {
|
||||
if (!config || !config.enabled) return;
|
||||
const banner = document.getElementById('privacy-banner');
|
||||
if (banner == null) return;
|
||||
|
||||
if (config.dismissal === 'dismissible' &&
|
||||
localStorage.getItem(storageKey(location.href)) === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
(banner.querySelector('.privacy-banner-title') as HTMLElement).textContent =
|
||||
config.title;
|
||||
const bodyHost = banner.querySelector('.privacy-banner-body') as HTMLElement;
|
||||
bodyHost.textContent = '';
|
||||
for (const line of config.body.split(/\r?\n/)) {
|
||||
const p = document.createElement('p');
|
||||
p.textContent = line;
|
||||
bodyHost.appendChild(p);
|
||||
}
|
||||
const linkHost = banner.querySelector('.privacy-banner-link') as HTMLElement;
|
||||
if (config.learnMoreUrl) {
|
||||
const a = document.createElement('a');
|
||||
a.href = config.learnMoreUrl;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.textContent = 'Learn more';
|
||||
linkHost.replaceChildren(a);
|
||||
} else {
|
||||
linkHost.replaceChildren();
|
||||
}
|
||||
const closeBtn = banner.querySelector('#privacy-banner-close') as HTMLElement | null;
|
||||
if (config.dismissal === 'dismissible' && closeBtn) {
|
||||
closeBtn.hidden = false;
|
||||
closeBtn.addEventListener('click', () => {
|
||||
banner.hidden = true;
|
||||
try { localStorage.setItem(storageKey(location.href), '1'); } catch (_e) { /* best effort */ }
|
||||
});
|
||||
} else if (closeBtn) {
|
||||
closeBtn.hidden = true;
|
||||
}
|
||||
banner.hidden = false;
|
||||
};
|
||||
```
|
||||
|
||||
Called from `pad.ts` once after `postAceInit`, with
|
||||
`clientVars.privacyBanner`.
|
||||
|
||||
### Tests
|
||||
|
||||
- **Settings unit** (`src/tests/backend/specs/privacyBanner.ts`):
|
||||
default shape matches, malformed `dismissal` falls back to
|
||||
`'dismissible'` on load.
|
||||
- **Playwright**
|
||||
(`src/tests/frontend-new/specs/privacy_banner.spec.ts`):
|
||||
- disabled (default) → `#privacy-banner` stays `hidden`.
|
||||
- enabled + `sticky` → banner visible on load, no close button.
|
||||
- enabled + `dismissible` → close button toggles banner hidden and
|
||||
persists across reload via localStorage.
|
||||
- `learnMoreUrl` → `<a>` rendered with the right href, absent when
|
||||
null.
|
||||
- Body with two `\n\n` paragraphs → two `<p>` children.
|
||||
|
||||
Tests flip `settings.privacyBanner.enabled` at runtime and navigate to
|
||||
a fresh pad; no server restart needed.
|
||||
|
||||
### Docs
|
||||
|
||||
- Add a short section to `doc/privacy.md` describing the banner and
|
||||
how to configure it.
|
||||
- Add a one-line pointer from `doc/settings.md`'s existing layout to
|
||||
the privacy doc if `settings.md` has a section for this kind of
|
||||
block; otherwise leave `settings.json.template`'s inline comments as
|
||||
the authoritative reference.
|
||||
|
||||
## Risk / migration
|
||||
|
||||
- Default `enabled: false` keeps the UI quiet for every existing
|
||||
instance.
|
||||
- Plain-text + textContent rendering avoids XSS even if operators
|
||||
copy-paste raw HTML into `body`.
|
||||
- localStorage key is scoped per-origin, so multi-tenant proxy setups
|
||||
won't cross-contaminate dismissal state.
|
||||
@ -241,6 +241,17 @@
|
||||
**/
|
||||
"enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:false}",
|
||||
|
||||
/*
|
||||
* Optional privacy banner. See settings.json.template for full field docs.
|
||||
*/
|
||||
"privacyBanner": {
|
||||
"enabled": "${PRIVACY_BANNER_ENABLED:false}",
|
||||
"title": "${PRIVACY_BANNER_TITLE:Privacy notice}",
|
||||
"body": "${PRIVACY_BANNER_BODY:This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.}",
|
||||
"learnMoreUrl": "${PRIVACY_BANNER_LEARN_MORE_URL:null}",
|
||||
"dismissal": "${PRIVACY_BANNER_DISMISSAL:dismissible}"
|
||||
},
|
||||
|
||||
/*
|
||||
* Node native SSL support
|
||||
*
|
||||
|
||||
@ -725,6 +725,24 @@
|
||||
**/
|
||||
"enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:false}",
|
||||
|
||||
/*
|
||||
* Optional privacy banner shown once the pad loads. Disabled by default.
|
||||
*
|
||||
* enabled — toggle the feature
|
||||
* title — plain-text heading (HTML is escaped)
|
||||
* body — plain-text body; newlines become paragraph breaks
|
||||
* learnMoreUrl — optional URL rendered as a "Learn more" link
|
||||
* dismissal — "dismissible" (close button, stored in localStorage)
|
||||
* or "sticky" (always shown, no close button)
|
||||
*/
|
||||
"privacyBanner": {
|
||||
"enabled": false,
|
||||
"title": "Privacy notice",
|
||||
"body": "This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.",
|
||||
"learnMoreUrl": null,
|
||||
"dismissal": "dismissible"
|
||||
},
|
||||
|
||||
/*
|
||||
* From Etherpad 1.8.5 onwards, when Etherpad is in production mode commits from individual users are rate limited
|
||||
*
|
||||
|
||||
@ -33,6 +33,7 @@ import padutils from '../../static/js/pad_utils';
|
||||
import readOnlyManager from '../db/ReadOnlyManager';
|
||||
import settings, {
|
||||
exportAvailable,
|
||||
getPublicPrivacyBanner,
|
||||
sofficeAvailable
|
||||
} from '../utils/Settings';
|
||||
import {anonymizeIp} from '../utils/anonymizeIp';
|
||||
@ -1157,6 +1158,10 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
|
||||
enableDarkMode: settings.enableDarkMode,
|
||||
enablePadWideSettings: settings.enablePadWideSettings,
|
||||
padDeletionToken,
|
||||
// Allow-listed copy — settings.privacyBanner could carry extra nested
|
||||
// keys from a hand-edited settings.json; sending those by reference
|
||||
// would leak them to every browser. See getPublicPrivacyBanner().
|
||||
privacyBanner: getPublicPrivacyBanner(),
|
||||
automaticReconnectionTimeout: settings.automaticReconnectionTimeout,
|
||||
initialRevisionList: [],
|
||||
initialOptions: pad.getPadSettings(),
|
||||
|
||||
@ -176,6 +176,13 @@ export type SettingsType = {
|
||||
enableDarkMode: boolean,
|
||||
enablePadWideSettings: boolean,
|
||||
allowPadDeletionByAllUsers: boolean,
|
||||
privacyBanner: {
|
||||
enabled: boolean,
|
||||
title: string,
|
||||
body: string,
|
||||
learnMoreUrl: string | null,
|
||||
dismissal: 'dismissible' | 'sticky',
|
||||
},
|
||||
skinName: string | null,
|
||||
skinVariants: string,
|
||||
ip: string,
|
||||
@ -313,7 +320,7 @@ export type SettingsType = {
|
||||
requireAdminForStatus: boolean,
|
||||
},
|
||||
adminEmail: string | null,
|
||||
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enablePadWideSettings">,
|
||||
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enablePadWideSettings" | "privacyBanner">,
|
||||
}
|
||||
|
||||
const settings: SettingsType = {
|
||||
@ -361,6 +368,14 @@ const settings: SettingsType = {
|
||||
enableDarkMode: true,
|
||||
enablePadWideSettings: false,
|
||||
allowPadDeletionByAllUsers: false,
|
||||
privacyBanner: {
|
||||
enabled: false,
|
||||
title: 'Privacy notice',
|
||||
body: 'This instance processes pad content on our servers. ' +
|
||||
'See the linked policy for retention and how to request erasure.',
|
||||
learnMoreUrl: null,
|
||||
dismissal: 'dismissible',
|
||||
},
|
||||
/*
|
||||
* Skin name.
|
||||
*
|
||||
@ -718,11 +733,25 @@ const settings: SettingsType = {
|
||||
skinName: settings.skinName,
|
||||
skinVariants: settings.skinVariants,
|
||||
enablePadWideSettings: settings.enablePadWideSettings,
|
||||
privacyBanner: getPublicPrivacyBanner(),
|
||||
}
|
||||
},
|
||||
gitVersion: getGitCommit(),
|
||||
}
|
||||
|
||||
// Build the wire-shape of `privacyBanner` for clientVars / getPublicSettings().
|
||||
// The settings file is operator-controlled and `_.defaults()` (used by
|
||||
// storeSettings) preserves unknown nested keys at runtime. Returning a literal
|
||||
// instead of `settings.privacyBanner` itself stops a typo or copy-paste from
|
||||
// shipping arbitrary extra keys to every browser.
|
||||
export const getPublicPrivacyBanner = () => ({
|
||||
enabled: settings.privacyBanner.enabled,
|
||||
title: settings.privacyBanner.title,
|
||||
body: settings.privacyBanner.body,
|
||||
learnMoreUrl: settings.privacyBanner.learnMoreUrl,
|
||||
dismissal: settings.privacyBanner.dismissal,
|
||||
});
|
||||
|
||||
export default settings;
|
||||
// CJS compatibility: plugins use require('ep_etherpad-lite/node/utils/Settings')
|
||||
// and expect settings properties directly on the module object, not under .default
|
||||
@ -1033,6 +1062,21 @@ export const reloadSettings = () => {
|
||||
settings.ipLogging = 'anonymous';
|
||||
}
|
||||
|
||||
// Validate `privacyBanner.dismissal`. The client treats every value other
|
||||
// than the exact strings 'dismissible' and 'sticky' as "no special
|
||||
// handling", which silently degrades a misconfigured 'sticky' to a
|
||||
// dismissible-shaped notice (and vice versa). Coerce to the safer default
|
||||
// and warn so the operator sees the typo.
|
||||
const validDismissal = ['dismissible', 'sticky'];
|
||||
if (settings.privacyBanner != null
|
||||
&& !validDismissal.includes(settings.privacyBanner.dismissal as any)) {
|
||||
logger.warn(
|
||||
`privacyBanner.dismissal="${settings.privacyBanner.dismissal}" is ` +
|
||||
`not one of ${validDismissal.join(', ')}; falling back to ` +
|
||||
`"dismissible".`);
|
||||
settings.privacyBanner.dismissal = 'dismissible';
|
||||
}
|
||||
|
||||
// Init logging config
|
||||
settings.logconfig = defaultLogConfig(
|
||||
settings.loglevel ? settings.loglevel : defaultLogLevel,
|
||||
|
||||
@ -53,6 +53,7 @@ import {randomString} from "./pad_utils";
|
||||
const socketio = require('./socketio');
|
||||
|
||||
const hooks = require('./pluginfw/hooks');
|
||||
import {showPrivacyBannerIfEnabled} from './privacy_banner';
|
||||
|
||||
import './pad_version_badge';
|
||||
|
||||
@ -717,6 +718,7 @@ const pad = {
|
||||
}
|
||||
|
||||
showDeletionTokenModalIfPresent();
|
||||
showPrivacyBannerIfEnabled((clientVars as any).privacyBanner);
|
||||
|
||||
hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad});
|
||||
};
|
||||
|
||||
106
src/static/js/privacy_banner.ts
Normal file
106
src/static/js/privacy_banner.ts
Normal file
@ -0,0 +1,106 @@
|
||||
'use strict';
|
||||
|
||||
type BannerConfig = {
|
||||
enabled: boolean,
|
||||
title: string,
|
||||
body: string,
|
||||
learnMoreUrl: string | null,
|
||||
dismissal: 'dismissible' | 'sticky',
|
||||
};
|
||||
|
||||
const storageKey = (url: string): string => {
|
||||
try {
|
||||
return `etherpad.privacyBanner.dismissed:${new URL(url).origin}`;
|
||||
} catch (_e) {
|
||||
return 'etherpad.privacyBanner.dismissed';
|
||||
}
|
||||
};
|
||||
|
||||
// Only http(s) and mailto: are allowed for the "Learn more" link, so a
|
||||
// misconfigured privacyBanner.learnMoreUrl cannot smuggle a javascript:,
|
||||
// data:, or vbscript: URL into the anchor and execute script on click.
|
||||
const SAFE_URL_SCHEMES = new Set(['http:', 'https:', 'mailto:']);
|
||||
const safeUrl = (href: string | null | undefined): string | null => {
|
||||
if (typeof href !== 'string' || href === '') return null;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(href, location.href);
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
if (!SAFE_URL_SCHEMES.has(parsed.protocol)) return null;
|
||||
return parsed.href;
|
||||
};
|
||||
|
||||
// Build a jQuery DOM fragment for the gritter `text` parameter. Each line of
|
||||
// the body becomes its own <p> (mirrors what the original config supports), and
|
||||
// an optional "Learn more" anchor is appended only after the URL has passed
|
||||
// through safeUrl().
|
||||
const buildBody = (config: BannerConfig): JQuery => {
|
||||
const $ = (window as any).$;
|
||||
const wrap = $('<div>');
|
||||
for (const line of (config.body || '').split(/\r?\n/)) {
|
||||
wrap.append($('<p>').text(line));
|
||||
}
|
||||
const safeHref = safeUrl(config.learnMoreUrl);
|
||||
if (safeHref != null) {
|
||||
// `noreferrer` matches the existing pattern in pad_utils.ts so the pad
|
||||
// URL doesn't leak to the operator-configured external policy site as a
|
||||
// Referer header. `noopener` keeps target=_blank from sharing the
|
||||
// window.opener handle.
|
||||
wrap.append($('<p>').append(
|
||||
$('<a>')
|
||||
.attr('href', safeHref)
|
||||
.attr('target', '_blank')
|
||||
.attr('rel', 'noreferrer noopener')
|
||||
.text('Learn more')));
|
||||
}
|
||||
return wrap;
|
||||
};
|
||||
|
||||
export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => {
|
||||
if (!config || !config.enabled) return;
|
||||
const $ = (window as any).$;
|
||||
if (!$ || !$.gritter || typeof $.gritter.add !== 'function') return;
|
||||
|
||||
// Server-side reloadSettings() coerces unknown values to 'dismissible' with a
|
||||
// warn, but if a custom build / hot-reload path skips that validation we
|
||||
// still must not fall through to "treats unknown as sticky" (which is the
|
||||
// less safe interpretation — an operator who fat-fingered "dismisable"
|
||||
// probably meant the dismissable mode they wrote).
|
||||
const dismissal = config.dismissal === 'sticky' ? 'sticky' : 'dismissible';
|
||||
|
||||
if (dismissal === 'dismissible') {
|
||||
try {
|
||||
if (localStorage.getItem(storageKey(location.href)) === '1') return;
|
||||
} catch (_e) { /* proceed without persistence */ }
|
||||
}
|
||||
|
||||
// Reused class lets the Playwright spec target this specific gritter without
|
||||
// affecting its appearance — the gritter looks like every other gritter on
|
||||
// the page.
|
||||
$.gritter.add({
|
||||
title: config.title || '',
|
||||
text: buildBody(config),
|
||||
sticky: true,
|
||||
position: 'bottom',
|
||||
class_name: 'privacy-notice',
|
||||
before_close: () => {
|
||||
if (dismissal !== 'dismissible') return;
|
||||
try {
|
||||
localStorage.setItem(storageKey(location.href), '1');
|
||||
} catch (_e) { /* best-effort */ }
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// End-to-end test hook. The privacy_banner module is bundled into pad.js so
|
||||
// the Playwright spec at src/tests/frontend-new/specs/privacy_banner.spec.ts
|
||||
// has no other way to reach into the real showPrivacyBannerIfEnabled — without
|
||||
// this it can only toy with the DOM and never proves the config-to-DOM wiring.
|
||||
// Gated on navigator.webdriver so the global is invisible in real browsers
|
||||
// (Playwright/ChromeDriver/Selenium set webdriver=true; humans don't), keeping
|
||||
// the disabled-by-default feature genuinely zero-side-effect in production.
|
||||
if (typeof navigator !== 'undefined' && (navigator as any).webdriver) {
|
||||
(globalThis as any).__etherpad_privacyBanner__ = {show: showPrivacyBannerIfEnabled};
|
||||
}
|
||||
@ -62,6 +62,13 @@ export type ClientVarPayload = {
|
||||
userColor: number,
|
||||
hideChat?: boolean,
|
||||
padOptions: PadOption,
|
||||
privacyBanner?: {
|
||||
enabled: boolean,
|
||||
title: string,
|
||||
body: string,
|
||||
learnMoreUrl: string | null,
|
||||
dismissal: 'dismissible' | 'sticky',
|
||||
},
|
||||
padId: string,
|
||||
colorPalette: string[],
|
||||
accountPrivs: {
|
||||
|
||||
@ -175,4 +175,3 @@
|
||||
font-family: monospace;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
|
||||
222
src/tests/frontend-new/specs/privacy_banner.spec.ts
Normal file
222
src/tests/frontend-new/specs/privacy_banner.spec.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import {expect, test, Page} from '@playwright/test';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
|
||||
type BannerConfig = {
|
||||
enabled: boolean,
|
||||
title: string,
|
||||
body: string,
|
||||
learnMoreUrl: string | null,
|
||||
dismissal: 'dismissible' | 'sticky',
|
||||
};
|
||||
|
||||
const STORAGE_PREFIX = 'etherpad.privacyBanner.dismissed:';
|
||||
// All gritters render into #gritter-container.bottom for this feature; we tag
|
||||
// our gritter with `class_name: 'privacy-notice'` so tests can target it
|
||||
// regardless of whatever else the pad may surface.
|
||||
const NOTICE = '#gritter-container.bottom .gritter-item.privacy-notice';
|
||||
|
||||
const freshPad = 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');
|
||||
// Drop any persisted dismissal flag from a previous test run on this origin
|
||||
// so dismissible scenarios start from a clean state regardless of order.
|
||||
await page.evaluate((prefix) => {
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const k = localStorage.key(i);
|
||||
if (k && k.startsWith(prefix)) localStorage.removeItem(k);
|
||||
}
|
||||
}, STORAGE_PREFIX);
|
||||
return padId;
|
||||
};
|
||||
|
||||
const showBanner = (page: Page, config: BannerConfig) =>
|
||||
page.evaluate((cfg) => {
|
||||
(window as any).__etherpad_privacyBanner__.show(cfg);
|
||||
}, config);
|
||||
|
||||
test.describe('privacy banner (gritter-based)', () => {
|
||||
test.beforeEach(async ({context}) => {
|
||||
await context.clearCookies();
|
||||
});
|
||||
|
||||
test('disabled by default — no privacy gritter is shown', async ({page}) => {
|
||||
await freshPad(page);
|
||||
await expect(page.locator(NOTICE)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('enabled=false leaves the page free of a privacy gritter', async ({page}) => {
|
||||
await freshPad(page);
|
||||
await showBanner(page, {
|
||||
enabled: false,
|
||||
title: 'Should not render',
|
||||
body: 'Should not render',
|
||||
learnMoreUrl: null,
|
||||
dismissal: 'sticky',
|
||||
});
|
||||
await expect(page.locator(NOTICE)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('renders title, body paragraphs, and link as a sticky bottom gritter',
|
||||
async ({page}) => {
|
||||
await freshPad(page);
|
||||
await showBanner(page, {
|
||||
enabled: true,
|
||||
title: 'Privacy notice',
|
||||
body: 'First paragraph.\nSecond paragraph.',
|
||||
learnMoreUrl: 'https://example.com/privacy',
|
||||
dismissal: 'sticky',
|
||||
});
|
||||
const item = page.locator(NOTICE);
|
||||
await expect(item).toBeVisible();
|
||||
await expect(item).toHaveClass(/sticky/);
|
||||
await expect(item.locator('.gritter-title')).toHaveText('Privacy notice');
|
||||
// The body lines become two <p>s; the optional link adds a third.
|
||||
const paragraphs = item.locator('.gritter-content > p, .gritter-content div p');
|
||||
await expect(paragraphs).toHaveCount(3);
|
||||
await expect(paragraphs.nth(0)).toHaveText('First paragraph.');
|
||||
await expect(paragraphs.nth(1)).toHaveText('Second paragraph.');
|
||||
const link = item.locator('a');
|
||||
await expect(link).toHaveAttribute('href', 'https://example.com/privacy');
|
||||
await expect(link).toHaveAttribute('rel', 'noreferrer noopener');
|
||||
await expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
test('dismissible — clicking gritter close persists flag in localStorage',
|
||||
async ({page}) => {
|
||||
await freshPad(page);
|
||||
await showBanner(page, {
|
||||
enabled: true,
|
||||
title: 'Privacy notice',
|
||||
body: 'Body.',
|
||||
learnMoreUrl: null,
|
||||
dismissal: 'dismissible',
|
||||
});
|
||||
const item = page.locator(NOTICE);
|
||||
await expect(item).toBeVisible();
|
||||
await item.locator('.gritter-close').click();
|
||||
await expect(page.locator(NOTICE)).toHaveCount(0);
|
||||
|
||||
const flag = await page.evaluate(
|
||||
(prefix) => localStorage.getItem(`${prefix}${location.origin}`),
|
||||
STORAGE_PREFIX);
|
||||
expect(flag).toBe('1');
|
||||
});
|
||||
|
||||
test('dismissible — pre-existing localStorage flag suppresses the gritter',
|
||||
async ({page}) => {
|
||||
await freshPad(page);
|
||||
await page.evaluate(
|
||||
(prefix) => localStorage.setItem(`${prefix}${location.origin}`, '1'),
|
||||
STORAGE_PREFIX);
|
||||
await showBanner(page, {
|
||||
enabled: true,
|
||||
title: 'Privacy notice',
|
||||
body: 'Body.',
|
||||
learnMoreUrl: null,
|
||||
dismissal: 'dismissible',
|
||||
});
|
||||
await expect(page.locator(NOTICE)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('sticky — closing the gritter does NOT persist a dismissal flag',
|
||||
async ({page}) => {
|
||||
// sticky mode means "show on every load"; the close button still
|
||||
// works for the current session but must not store a flag.
|
||||
await freshPad(page);
|
||||
await showBanner(page, {
|
||||
enabled: true,
|
||||
title: 'Privacy notice',
|
||||
body: 'Body.',
|
||||
learnMoreUrl: null,
|
||||
dismissal: 'sticky',
|
||||
});
|
||||
const item = page.locator(NOTICE);
|
||||
await expect(item).toBeVisible();
|
||||
await item.locator('.gritter-close').click();
|
||||
await expect(page.locator(NOTICE)).toHaveCount(0);
|
||||
|
||||
const flag = await page.evaluate(
|
||||
(prefix) => localStorage.getItem(`${prefix}${location.origin}`),
|
||||
STORAGE_PREFIX);
|
||||
expect(flag).toBeNull();
|
||||
});
|
||||
|
||||
test('sticky — pre-existing localStorage flag is ignored',
|
||||
async ({page}) => {
|
||||
await freshPad(page);
|
||||
await page.evaluate(
|
||||
(prefix) => localStorage.setItem(`${prefix}${location.origin}`, '1'),
|
||||
STORAGE_PREFIX);
|
||||
await showBanner(page, {
|
||||
enabled: true,
|
||||
title: 'Privacy notice',
|
||||
body: 'Body.',
|
||||
learnMoreUrl: null,
|
||||
dismissal: 'sticky',
|
||||
});
|
||||
await expect(page.locator(NOTICE)).toBeVisible();
|
||||
});
|
||||
|
||||
test('javascript: learnMoreUrl is rejected — no anchor rendered',
|
||||
async ({page}) => {
|
||||
await freshPad(page);
|
||||
await showBanner(page, {
|
||||
enabled: true,
|
||||
title: 'Privacy notice',
|
||||
body: 'Body.',
|
||||
learnMoreUrl: 'javascript:alert(1)',
|
||||
dismissal: 'sticky',
|
||||
});
|
||||
await expect(page.locator(`${NOTICE} a`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('data: learnMoreUrl is rejected — no anchor rendered', async ({page}) => {
|
||||
await freshPad(page);
|
||||
await showBanner(page, {
|
||||
enabled: true,
|
||||
title: 'Privacy notice',
|
||||
body: 'Body.',
|
||||
learnMoreUrl: 'data:text/html,<script>alert(1)</script>',
|
||||
dismissal: 'sticky',
|
||||
});
|
||||
await expect(page.locator(`${NOTICE} a`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('unknown dismissal value is treated as dismissible (defense-in-depth)',
|
||||
async ({page}) => {
|
||||
// Server-side reloadSettings() coerces unknown strings to
|
||||
// 'dismissible' with a warn, but the client guards too in case a
|
||||
// hot-reload or custom build path skips that validation.
|
||||
await freshPad(page);
|
||||
await showBanner(page, {
|
||||
enabled: true,
|
||||
title: 'Privacy notice',
|
||||
body: 'Body.',
|
||||
learnMoreUrl: null,
|
||||
dismissal: 'wat' as any,
|
||||
});
|
||||
const item = page.locator(NOTICE);
|
||||
await expect(item).toBeVisible();
|
||||
await item.locator('.gritter-close').click();
|
||||
await expect(page.locator(NOTICE)).toHaveCount(0);
|
||||
const flag = await page.evaluate(
|
||||
(prefix) => localStorage.getItem(`${prefix}${location.origin}`),
|
||||
STORAGE_PREFIX);
|
||||
expect(flag).toBe('1');
|
||||
});
|
||||
|
||||
test('mailto: learnMoreUrl is allowed', async ({page}) => {
|
||||
await freshPad(page);
|
||||
await showBanner(page, {
|
||||
enabled: true,
|
||||
title: 'Privacy notice',
|
||||
body: 'Body.',
|
||||
learnMoreUrl: 'mailto:privacy@example.com',
|
||||
dismissal: 'sticky',
|
||||
});
|
||||
await expect(page.locator(`${NOTICE} a`))
|
||||
.toHaveAttribute('href', 'mailto:privacy@example.com');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user