diff --git a/admin/src/components/settings/__tests__/stringEscapes.test.ts b/admin/src/components/settings/__tests__/stringEscapes.test.ts
new file mode 100644
index 00000000000..7c32a0accbf
--- /dev/null
+++ b/admin/src/components/settings/__tests__/stringEscapes.test.ts
@@ -0,0 +1,45 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { applyEdits, modify, parse } from 'jsonc-parser';
+
+import { escapeForInput, unescapeFromInput } from '../stringEscapes.ts';
+
+// Regression for https://github.com/ether/etherpad/issues/8211.
+
+test('newlines and backslashes are shown as JSON escapes', () => {
+ assert.equal(escapeForInput('Welcome\n\ntest\n'), 'Welcome\\n\\ntest\\n');
+ assert.equal(escapeForInput('C:\\dir\tx\r'), 'C:\\\\dir\\tx\\r');
+ assert.equal(escapeForInput('\u0001'), '\\u0001');
+});
+
+test('quotes and slashes stay readable', () => {
+ assert.equal(escapeForInput('say "hi" https://etherpad.org'), 'say "hi" https://etherpad.org');
+});
+
+test('typed escape sequences decode to the characters they name', () => {
+ assert.equal(unescapeFromInput('Welcome\\n\\ntest\\n'), 'Welcome\n\ntest\n');
+ assert.equal(unescapeFromInput('a\\"b\\/c\\\\d\\te\\u00e9'), 'a"b/c\\d\te\u00e9');
+ assert.equal(unescapeFromInput('plain "quoted" text'), 'plain "quoted" text');
+});
+
+test('invalid or incomplete escapes are rejected', () => {
+ assert.equal(unescapeFromInput('trailing\\'), null);
+ assert.equal(unescapeFromInput('bad \\q escape'), null);
+ assert.equal(unescapeFromInput('short \\u12'), null);
+});
+
+test('round-trips arbitrary strings', () => {
+ for (const s of ['', 'x', 'Welcome to Etherpad!\n\nGet involved\n', 'a\\n', '"\\"', '\u2028\u0000']) {
+ assert.equal(unescapeFromInput(escapeForInput(s)), s);
+ }
+});
+
+test('typed \\n is written to settings JSON as \\n, not \\\\n', () => {
+ const text = '{\n "defaultPadText": "old"\n}';
+ const decoded = unescapeFromInput('Welcome\\n\\ntest\\n');
+ const next = applyEdits(text, modify(text, ['defaultPadText'], decoded, {
+ formattingOptions: { tabSize: 2, insertSpaces: true, eol: '\n' },
+ }));
+ assert.ok(next.includes('"Welcome\\n\\ntest\\n"'), next);
+ assert.equal(parse(next).defaultPadText, 'Welcome\n\ntest\n');
+});
diff --git a/admin/src/components/settings/stringEscapes.ts b/admin/src/components/settings/stringEscapes.ts
new file mode 100644
index 00000000000..8d794f8b227
--- /dev/null
+++ b/admin/src/components/settings/stringEscapes.ts
@@ -0,0 +1,65 @@
+// admin/src/components/settings/stringEscapes.ts
+//
+// Form-view string widgets are single-line s. Browsers strip line
+// breaks from an 's value, and settings.json documents values such
+// as `"defaultPadText": "Line 1\nLine 2"` using JSON escape sequences. So
+// the widgets show and accept string values in that same escaped form:
+// a newline is displayed as `\n`, and typing `\n` stores a newline
+// (issue #8211). Double quotes and slashes are left as-is for readability;
+// their escaped forms (`\"`, `\/`) are still accepted on input.
+
+const SHORT_ESCAPES: Record = {
+ '\\': '\\\\',
+ '\n': '\\n',
+ '\r': '\\r',
+ '\t': '\\t',
+ '\b': '\\b',
+ '\f': '\\f',
+};
+
+const SHORT_UNESCAPES: Record = {
+ '"': '"',
+ '\\': '\\',
+ '/': '/',
+ b: '\b',
+ f: '\f',
+ n: '\n',
+ r: '\r',
+ t: '\t',
+};
+
+/** Turn a decoded string value into the escaped text shown in an input. */
+export const escapeForInput = (value: string): string =>
+ // eslint-disable-next-line no-control-regex
+ value.replace(/[\\\u0000-\u001f\u2028\u2029]/g, (c) =>
+ SHORT_ESCAPES[c] ?? `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
+
+/**
+ * Decode the escaped text typed into an input back into the string value.
+ * Returns null when the text contains an invalid or incomplete escape
+ * sequence (e.g. a trailing `\` while the user is still typing).
+ */
+export const unescapeFromInput = (text: string): string | null => {
+ let out = '';
+ for (let i = 0; i < text.length; i++) {
+ const c = text[i];
+ if (c !== '\\') {
+ out += c;
+ continue;
+ }
+ const next = text[i + 1];
+ if (next === undefined) return null;
+ if (next === 'u') {
+ const hex = text.slice(i + 2, i + 6);
+ if (!/^[0-9a-fA-F]{4}$/.test(hex)) return null;
+ out += String.fromCharCode(parseInt(hex, 16));
+ i += 5;
+ continue;
+ }
+ const decoded = SHORT_UNESCAPES[next];
+ if (decoded === undefined) return null;
+ out += decoded;
+ i += 1;
+ }
+ return out;
+};
diff --git a/admin/src/components/settings/widgets/EnvPill.tsx b/admin/src/components/settings/widgets/EnvPill.tsx
index 9ec97e949c4..9e4e33eda36 100644
--- a/admin/src/components/settings/widgets/EnvPill.tsx
+++ b/admin/src/components/settings/widgets/EnvPill.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { JSONPath } from 'jsonc-parser';
import type { EnvPlaceholder } from '../envPill';
+import { escapeForInput, unescapeFromInput } from '../stringEscapes';
const REDACTED = '[REDACTED]';
@@ -14,6 +15,14 @@ type Props = {
const sanitize = (s: string) => s.replace(/[}]/g, '');
+// Decode an escaped default. A `}` (e.g. from `\u007d`) would terminate the
+// `${VAR:default}` placeholder, so treat it as invalid rather than silently
+// dropping it.
+const decodeDefault = (s: string): string | null => {
+ const decoded = unescapeFromInput(s);
+ return decoded === null || decoded.includes('}') ? null : decoded;
+};
+
const formatDisplay = (v: unknown): string => {
if (v === null) return 'null';
if (typeof v === 'string') return v;
@@ -22,12 +31,21 @@ const formatDisplay = (v: unknown): string => {
export const EnvPill = ({ placeholder, path, onChange, resolvedValue }: Props) => {
const { t } = useTranslation();
- const initial = placeholder.defaultValue ?? '';
+ // defaultValue is sliced from the raw JSON text, so it is still escaped.
+ // Normalise it through decode/escape so it matches what StringInput shows
+ // (e.g. `https:\/\/` displays as `https://`) — see stringEscapes.ts.
+ const rawDefault = placeholder.defaultValue ?? '';
+ const decodedDefault = decodeDefault(rawDefault);
+ const initial = decodedDefault === null ? rawDefault : escapeForInput(decodedDefault);
const [draft, setDraft] = useState(initial);
+ const [invalid, setInvalid] = useState(false);
const focused = useRef(false);
useEffect(() => {
- if (!focused.current) setDraft(initial);
+ if (!focused.current) {
+ setDraft(initial);
+ setInvalid(false);
+ }
}, [initial]);
const id = `field-${path.join('.')}`;
@@ -58,12 +76,24 @@ export const EnvPill = ({ placeholder, path, onChange, resolvedValue }: Props) =
value={draft}
spellCheck={false}
aria-label={t('admin_settings.env_pill.input_aria', { variable: placeholder.variable })}
+ aria-invalid={invalid || undefined}
onFocus={() => { focused.current = true; }}
- onBlur={() => { focused.current = false; }}
+ onBlur={() => {
+ focused.current = false;
+ // Drop a rejected draft so the field never shows a value that
+ // was not applied to the settings text.
+ setDraft(initial);
+ setInvalid(false);
+ }}
onChange={e => {
const v = sanitize(e.target.value);
setDraft(v);
- onChange(v);
+ // The draft is in escaped form; hand the decoded value to the
+ // JSON writer so typed `\n` is stored as `\n`, not `\\n` (#8211).
+ // Incomplete escapes (a trailing `\`) are not propagated.
+ const decoded = decodeDefault(v);
+ setInvalid(decoded === null);
+ if (decoded !== null) onChange(decoded);
}}
/>
{hasResolved && !isRedacted && (
diff --git a/admin/src/components/settings/widgets/StringInput.tsx b/admin/src/components/settings/widgets/StringInput.tsx
index dd3efe51591..b4af9e2368d 100644
--- a/admin/src/components/settings/widgets/StringInput.tsx
+++ b/admin/src/components/settings/widgets/StringInput.tsx
@@ -1,4 +1,6 @@
+import { useEffect, useRef, useState } from 'react';
import type { JSONPath } from 'jsonc-parser';
+import { escapeForInput, unescapeFromInput } from '../stringEscapes';
type Props = {
value: string;
@@ -6,14 +8,46 @@ type Props = {
onChange: (next: string) => void;
};
-export const StringInput = ({ value, path, onChange }: Props) => (
- onChange(e.target.value)}
- />
-);
+// The input shows the value in its JSON-escaped form (see stringEscapes.ts)
+// so newlines survive the single-line and typed `\n` is stored as a
+// newline. While focused we keep the user's own text as a draft so partial
+// escapes (a lone trailing `\`) aren't reformatted mid-typing; invalid
+// drafts are not propagated.
+export const StringInput = ({ value, path, onChange }: Props) => {
+ const escaped = escapeForInput(value);
+ const [draft, setDraft] = useState(escaped);
+ const [invalid, setInvalid] = useState(false);
+ const focused = useRef(false);
+
+ useEffect(() => {
+ if (!focused.current) {
+ setDraft(escaped);
+ setInvalid(false);
+ }
+ }, [escaped]);
+
+ return (
+ { focused.current = true; }}
+ onBlur={() => {
+ focused.current = false;
+ setDraft(escaped);
+ setInvalid(false);
+ }}
+ onChange={e => {
+ const text = e.target.value;
+ setDraft(text);
+ const decoded = unescapeFromInput(text);
+ setInvalid(decoded === null);
+ if (decoded !== null) onChange(decoded);
+ }}
+ />
+ );
+};
diff --git a/admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx b/admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx
index 1ea2f3b2fc9..51a3780f3d2 100644
--- a/admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx
+++ b/admin/src/components/settings/widgets/__tests__/EnvPill.test.tsx
@@ -84,3 +84,22 @@ test('renders null resolved value as the string null', () => {
} as any));
assert.ok(html.includes('null'), `expected "null" in ${html}`);
});
+
+// https://github.com/ether/etherpad/issues/8211
+test('shows escaped defaults in their escaped form', () => {
+ const html = wrap(React.createElement(EnvPill, {
+ placeholder: { variable: 'DEFAULT_PAD_TEXT', defaultValue: 'Line 1\\nLine 2 https:\\/\\/x' },
+ path: ['defaultPadText'],
+ onChange: () => {},
+ }));
+ assert.ok(html.includes('value="Line 1\\nLine 2 https://x"'), html);
+});
+
+test('keeps a default whose decoded form contains } in raw form', () => {
+ const html = wrap(React.createElement(EnvPill, {
+ placeholder: { variable: 'X', defaultValue: 'a\\u007db' },
+ path: ['x'],
+ onChange: () => {},
+ }));
+ assert.ok(html.includes('value="a\\u007db"'), html);
+});
diff --git a/src/tests/frontend-new/admin-spec/adminsettings.spec.ts b/src/tests/frontend-new/admin-spec/adminsettings.spec.ts
index b82438d1bc7..c8d4cf3673b 100644
--- a/src/tests/frontend-new/admin-spec/adminsettings.spec.ts
+++ b/src/tests/frontend-new/admin-spec/adminsettings.spec.ts
@@ -433,6 +433,85 @@ test.describe('admin settings',()=> {
expect(effectiveText).toContain('[REDACTED]');
});
+ // Regression for https://github.com/ether/etherpad/issues/8211.
+ // settings.json.template also mentions `"defaultPadText"` inside its
+ // documentation comment, which precedes the real key; earlier specs may
+ // also have minified the file onto one line. So take the last match.
+ const lastDefaultPadText = (text: string) =>
+ [...text.matchAll(/"defaultPadText"\s*:\s*("(?:[^"\\]|\\.)*")/g)].pop();
+
+ // Form inputs are single-line, so string values are shown and edited in
+ // their JSON-escaped form (the same form used in settings.json). Typing
+ // `\n` must be saved as the JSON escape `\n` (a newline), not re-escaped
+ // to `\\n` (a literal backslash + n).
+ test('#8211 escape sequences typed in a form string field are saved unescaped', async ({page}) => {
+ await page.goto('http://localhost:9001/admin/settings');
+ await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
+ await page.getByTestId('mode-toggle-raw').click();
+ const raw = page.getByTestId('settings-raw-textarea');
+ await expect(raw).toBeVisible({timeout: 10000});
+ const original = await raw.inputValue();
+
+ await page.getByTestId('mode-toggle-form').click();
+ const field = page.getByTestId('field-defaultPadText');
+ await expect(field).toBeVisible({timeout: 10000});
+ // Existing newlines are displayed as `\n` rather than silently dropped
+ // by the single-line .
+ await expect(field).toHaveValue(/^Welcome to Etherpad!\\n\\nThis pad text/);
+ await field.fill('Welcome\\n\\ntest "quoted" C:\\\\dir\\n');
+ await saveSettings(page);
+
+ await page.reload();
+ await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
+ await expect(page.getByTestId('field-defaultPadText'))
+ .toHaveValue('Welcome\\n\\ntest "quoted" C:\\\\dir\\n');
+ await page.getByTestId('mode-toggle-raw').click();
+ const after = await page.getByTestId('settings-raw-textarea').inputValue();
+ const m = lastDefaultPadText(after);
+ expect(m).not.toBeUndefined();
+ expect(JSON.parse(m![1])).toEqual('Welcome\n\ntest "quoted" C:\\dir\n');
+
+ // Restore
+ await page.getByTestId('settings-raw-textarea').fill(original);
+ await saveSettings(page);
+ });
+
+ test('#8211 escape sequences typed in an env placeholder default are saved unescaped', async ({page}) => {
+ await page.goto('http://localhost:9001/admin/settings');
+ await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
+ await page.getByTestId('mode-toggle-raw').click();
+ const raw = page.getByTestId('settings-raw-textarea');
+ await expect(raw).toBeVisible({timeout: 10000});
+ const original = await raw.inputValue();
+
+ // The shape settings.json.docker uses for defaultPadText.
+ const m = lastDefaultPadText(original);
+ expect(m).not.toBeUndefined();
+ const withEnv = original.slice(0, m!.index) +
+ '"defaultPadText": "${DEFAULT_PAD_TEXT:Line 1\\nLine 2}"' +
+ original.slice(m!.index + m![0].length);
+ expect(withEnv).not.toEqual(original);
+ await raw.fill(withEnv);
+ await saveSettings(page);
+
+ await page.getByTestId('mode-toggle-form').click();
+ const pill = page.getByTestId('env-defaultPadText');
+ await expect(pill).toBeVisible({timeout: 10000});
+ await expect(pill).toHaveValue('Line 1\\nLine 2');
+ await pill.fill('Welcome\\n\\ntest\\n');
+ await saveSettings(page);
+
+ await page.reload();
+ await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
+ await page.getByTestId('mode-toggle-raw').click();
+ const after = await page.getByTestId('settings-raw-textarea').inputValue();
+ expect(after).toContain('"${DEFAULT_PAD_TEXT:Welcome\\n\\ntest\\n}"');
+
+ // Restore
+ await page.getByTestId('settings-raw-textarea').fill(original);
+ await saveSettings(page);
+ });
+
test('toggling form on broken raw JSON shows parse error banner', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
// Wait for settings to load (form view renders once socket emits settings).