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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 28 additions & 11 deletions src/node/db/Pad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,25 +590,42 @@ class Pad {
Object.assign(this, value);
if ('pool' in value) this.pool = new AttributePool().fromJsonable(value.pool);
} else {
if (text == null) {
// Auto-generated default content (settings.defaultPadText or whatever a
// padDefaultContent hook substitutes) is not written by the user who
// happens to open the pad first, so the text must not carry their author
// attribute — otherwise the welcome text shows up in the creator's
// authorship colour (issue #7885). Track whether the text came from the
// default-content path so its insert op can be attributed to the system
// author.
const usedDefaultContent = (text == null);
if (usedDefaultContent) {
const context = {pad: this, authorId, type: 'text', content: settings.defaultPadText};
await hooks.aCallAll('padDefaultContent', context);
if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`);
text = exports.cleanText(context.content);
}
// When the initial pad text is non-empty but no authorId was
// supplied (internal getPad calls during HTTP API setup,
// padDefaultContent flows, plugin-driven pad creation), fall back
// to the stable system author so the initial changeset's insert
// op carries an `author` attribute. Mirrors the same substitution
// The author *attribute* applied to the initial text — i.e. what colours
// it in the editor — is the stable system author when the content is
// auto-generated default text (#7885), or when non-empty text was
// supplied without an authorId (internal getPad calls during HTTP API
// setup, plugin-driven pad creation). The latter keeps the insert op
// carrying an `author` attribute, mirroring the substitution
// setText/appendText already do via spliceText.
const effectiveAuthorId =
(text.length > 0 && !authorId) ? Pad.SYSTEM_AUTHOR_ID : authorId;
const firstAttribs = effectiveAuthorId
? [['author', effectiveAuthorId] as [string, string]]
const attribAuthorId =
((usedDefaultContent || !authorId) && text.length > 0)
? Pad.SYSTEM_AUTHOR_ID : authorId;
const firstAttribs = attribAuthorId
? [['author', attribAuthorId] as [string, string]]
: undefined;
// The *revision* author (revs:0 meta.author) stays the real creator so
// pad ownership is preserved: isPadCreator() / the pad-wide settings gate
// and the deletion token all key off getRevisionAuthor(0). Only when no
// author was supplied at all do we fall back to the system author, so the
// initial revision still records a stable, non-empty author.
const revisionAuthorId =
authorId || (text.length > 0 ? Pad.SYSTEM_AUTHOR_ID : '');
const firstChangeset = makeSplice('\n', 0, 0, text, firstAttribs, this.pool);
await this.appendRevision(firstChangeset, effectiveAuthorId);
await this.appendRevision(firstChangeset, revisionAuthorId);
}
this.padSettings = Pad.normalizePadSettings(this.padSettings);
await hooks.aCallAll('padLoad', {pad: this});
Expand Down
52 changes: 52 additions & 0 deletions src/tests/backend/specs/Pad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,58 @@ describe(__filename, function () {
pad = await padManager.getPad(padId);
assert.equal(pad!.text(), `${want}\n`);
});

// Returns the set of author IDs actually applied to the pad's text, by
// resolving every attribute marker in the current AText against the pool.
// This is what colours the text in the editor — distinct from
// getRevisionAuthor()/getAllAuthors() which also reflect pool bookkeeping.
const authorsAppliedToText = (p: any): Set<string> => {
const applied = new Set<string>();
const attribs: string = p.atext.attribs;
for (const m of attribs.matchAll(/\*([0-9a-z]+)/g)) {
const attr = p.pool.getAttrib(parseInt(m[1], 36));
if (attr && attr[0] === 'author' && attr[1] !== '') applied.add(attr[1]);
}
return applied;
};

it('does not colour default content with the creating user (issue #7885)',
async function () {
// When a user opens a brand-new pad, CLIENT_READY calls
// getPad(padId, null, session.author). The default welcome text is not
// written by that user, so its insert op must not carry their author
// attribute (which would colour it in the creator's colour). The system
// author owns the text instead.
const creator = await authorManager.getAuthorId(`t.${padId}`);
pad = await padManager.getPad(padId, null, creator);
const applied = authorsAppliedToText(pad);
assert(!applied.has(creator),
`default text must not be coloured with the creating author ${creator}`);
assert(applied.has('a.etherpad-system'),
'default text should be owned by the system author');
});

it('keeps the creating user as the revision-0 author so pad ownership is preserved',
async function () {
// isPadCreator()/the pad-wide settings gate and the deletion token all
// key off getRevisionAuthor(0). Reassigning the welcome-text colour to
// the system author (above) must not strip the creator's ownership.
const creator = await authorManager.getAuthorId(`t.${padId}`);
pad = await padManager.getPad(padId, null, creator);
assert.equal(await (pad as any).getRevisionAuthor(0), creator,
'the creating user must remain the revision-0 author');
});

it('still colours explicitly provided content with the creating author',
async function () {
// A real author providing real text (e.g. API createPad with text)
// keeps ownership of that text — only auto-generated default content is
// reassigned to the system author.
const creator = await authorManager.getAuthorId(`t.${padId}`);
pad = await padManager.getPad(padId, 'real user content', creator);
assert(authorsAppliedToText(pad).has(creator),
'explicitly provided text should be coloured with the creating author');
});
});

describe('normalizePadSettings lang (issue #7586)', function () {
Expand Down
20 changes: 13 additions & 7 deletions src/tests/frontend-new/specs/wcag_author_color.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,23 @@ const wcagRatio = (rgb1: string, rgb2: string): number => {
const renderedAuthorContrast = async (page: Page) => {
const body = await getPadBody(page);
await body.click();
await page.keyboard.type('contrast smoke');
const typed = 'contrast smoke';
await page.keyboard.type(typed);
await page.waitForTimeout(300);
// The author span is the inner-frame <span class="author-..."> wrapping
// the typed text. Read its computed bg + the inherited text colour.
const result = await page.frame('ace_inner')!.evaluate(() => {
const span = document.querySelector(
'#innerdocbody span[class*="author-"]:not([class*="anonymous"])') as HTMLElement | null;
// The author span is the inner-frame <span class="author-..."> wrapping the
// text WE just typed. Match by text content rather than picking the first
// author span on the page: the default welcome text is owned by the system
// author (issue #7885) and renders with no background colour, so the first
// author span is no longer the current user's. Read the span's computed bg +
// the inherited text colour.
const result = await page.frame('ace_inner')!.evaluate((needle) => {
const spans = Array.from(
document.querySelectorAll('#innerdocbody span[class*="author-"]')) as HTMLElement[];
const span = spans.find((s) => (s.textContent || '').includes(needle));
if (!span) return null;
const cs = getComputedStyle(span);
return {bg: cs.backgroundColor, color: cs.color};
});
}, typed);
return result;
};

Expand Down
Loading