refactor: move shared utilities to lib/shared/ sub-packages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
40
frontend/src/lib/shared/discussion/comment.spec.ts
Normal file
40
frontend/src/lib/shared/discussion/comment.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractQuote } from './comment';
|
||||
|
||||
describe('extractQuote', () => {
|
||||
it('returns null quote and full body for plain text', () => {
|
||||
const result = extractQuote('Hello world');
|
||||
expect(result.quote).toBeNull();
|
||||
expect(result.body).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('extracts quote and body with double newline separator', () => {
|
||||
const result = extractQuote('> "Some quoted text"\n\nReply body');
|
||||
expect(result.quote).toBe('Some quoted text');
|
||||
expect(result.body).toBe('Reply body');
|
||||
});
|
||||
|
||||
it('extracts quote and body with single newline separator', () => {
|
||||
const result = extractQuote('> "Quote"\nBody');
|
||||
expect(result.quote).toBe('Quote');
|
||||
expect(result.body).toBe('Body');
|
||||
});
|
||||
|
||||
it('returns null quote when format does not match', () => {
|
||||
const result = extractQuote('> Not a quote format');
|
||||
expect(result.quote).toBeNull();
|
||||
expect(result.body).toBe('> Not a quote format');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
const result = extractQuote('');
|
||||
expect(result.quote).toBeNull();
|
||||
expect(result.body).toBe('');
|
||||
});
|
||||
|
||||
it('does not match when quotes are missing', () => {
|
||||
const result = extractQuote('> just a blockquote\n\nbody');
|
||||
expect(result.quote).toBeNull();
|
||||
expect(result.body).toBe('> just a blockquote\n\nbody');
|
||||
});
|
||||
});
|
||||
5
frontend/src/lib/shared/discussion/comment.ts
Normal file
5
frontend/src/lib/shared/discussion/comment.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function extractQuote(content: string): { quote: string | null; body: string } {
|
||||
const match = content.match(/^>\s*"(.+?)"\s*\n\n?([\s\S]*)$/);
|
||||
if (match) return { quote: match[1], body: match[2] };
|
||||
return { quote: null, body: content };
|
||||
}
|
||||
14
frontend/src/lib/shared/discussion/commentDeepLink.spec.ts
Normal file
14
frontend/src/lib/shared/discussion/commentDeepLink.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildCommentHref } from './commentDeepLink';
|
||||
|
||||
describe('buildCommentHref', () => {
|
||||
it('includes both commentId and annotationId when annotationId is present', () => {
|
||||
const href = buildCommentHref('doc-1', 'comment-2', 'annot-3');
|
||||
expect(href).toBe('/documents/doc-1?commentId=comment-2&annotationId=annot-3');
|
||||
});
|
||||
|
||||
it('omits annotationId when null', () => {
|
||||
const href = buildCommentHref('doc-1', 'comment-2', null);
|
||||
expect(href).toBe('/documents/doc-1?commentId=comment-2');
|
||||
});
|
||||
});
|
||||
8
frontend/src/lib/shared/discussion/commentDeepLink.ts
Normal file
8
frontend/src/lib/shared/discussion/commentDeepLink.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function buildCommentHref(
|
||||
documentId: string,
|
||||
commentId: string,
|
||||
annotationId: string | null
|
||||
): string {
|
||||
const base = `/documents/${documentId}?commentId=${commentId}`;
|
||||
return annotationId ? `${base}&annotationId=${annotationId}` : base;
|
||||
}
|
||||
350
frontend/src/lib/shared/discussion/mention.spec.ts
Normal file
350
frontend/src/lib/shared/discussion/mention.spec.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
detectMention,
|
||||
escapeHtml,
|
||||
extractContent,
|
||||
renderBody,
|
||||
renderTranscriptionBody
|
||||
} from './mention';
|
||||
import type { MentionDTO, PersonMention } from '$lib/types';
|
||||
|
||||
// ─── escapeHtml ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes ampersand', () => {
|
||||
expect(escapeHtml('AT&T')).toBe('AT&T');
|
||||
});
|
||||
|
||||
it('escapes less-than and greater-than', () => {
|
||||
expect(escapeHtml('<script>')).toBe('<script>');
|
||||
});
|
||||
|
||||
it('escapes double quote', () => {
|
||||
expect(escapeHtml('say "hi"')).toBe('say "hi"');
|
||||
});
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(escapeHtml('')).toBe('');
|
||||
});
|
||||
|
||||
it('escapes ampersand before other entities to avoid double-encoding', () => {
|
||||
expect(escapeHtml('a&<b')).toBe('a&<b');
|
||||
});
|
||||
|
||||
it('escapes apostrophe to '', () => {
|
||||
expect(escapeHtml("d'Artagnan")).toBe('d'Artagnan');
|
||||
});
|
||||
|
||||
it('does not collapse already-encoded entities (re-escapes the &)', () => {
|
||||
// escapeHtml is idempotent by composition: the second pass re-escapes
|
||||
// the & that was added by the first. Pin the property so the helper
|
||||
// can't be "cleverly" optimised to skip it.
|
||||
expect(escapeHtml('&')).toBe('&amp;');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── detectMention ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('detectMention', () => {
|
||||
it('returns null when text has no @', () => {
|
||||
expect(detectMention('hello world', 11)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when @ is not the most recent trigger word', () => {
|
||||
// cursor is past a completed mention (next word started)
|
||||
expect(detectMention('hello @Hans Müller more', 22)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty string immediately after @', () => {
|
||||
expect(detectMention('hello @', 7)).toBe('');
|
||||
});
|
||||
|
||||
it('returns query text after @', () => {
|
||||
expect(detectMention('hello @Han', 10)).toBe('Han');
|
||||
});
|
||||
|
||||
it('returns null when @ is preceded by a letter (email address pattern)', () => {
|
||||
expect(detectMention('user@example', 12)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns query for @ at the very start of string', () => {
|
||||
expect(detectMention('@Hans', 5)).toBe('Hans');
|
||||
});
|
||||
|
||||
it('returns null when cursor is before the @', () => {
|
||||
expect(detectMention('@Hans', 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── extractContent ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('extractContent', () => {
|
||||
it('returns empty arrays for empty string', () => {
|
||||
const result = extractContent('', []);
|
||||
expect(result.content).toBe('');
|
||||
expect(result.mentionedUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns plain content unchanged when no candidates', () => {
|
||||
const result = extractContent('Hello world', []);
|
||||
expect(result.content).toBe('Hello world');
|
||||
expect(result.mentionedUserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts user id when @FirstName LastName is in content', () => {
|
||||
const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||
const result = extractContent('Hey @Hans Müller how are you?', candidates);
|
||||
expect(result.mentionedUserIds).toContain('uuid-1');
|
||||
});
|
||||
|
||||
it('deduplicates user ids when same user mentioned twice', () => {
|
||||
const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||
const result = extractContent('@Hans Müller and @Hans Müller again', candidates);
|
||||
expect(result.mentionedUserIds).toHaveLength(1);
|
||||
expect(result.mentionedUserIds).toContain('uuid-1');
|
||||
});
|
||||
|
||||
it('collects multiple distinct users', () => {
|
||||
const candidates: MentionDTO[] = [
|
||||
{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' },
|
||||
{ id: 'uuid-2', firstName: 'Anna', lastName: 'Schmidt' }
|
||||
];
|
||||
const result = extractContent('@Hans Müller and @Anna Schmidt', candidates);
|
||||
expect(result.mentionedUserIds).toContain('uuid-1');
|
||||
expect(result.mentionedUserIds).toContain('uuid-2');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── renderBody ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('renderBody', () => {
|
||||
it('returns escaped plain text when no mentions', () => {
|
||||
expect(renderBody('Hello world', [])).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('escapes < and > in content', () => {
|
||||
const result = renderBody('<script>alert(1)</script>', []);
|
||||
expect(result).toContain('<script>');
|
||||
expect(result).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes & in content', () => {
|
||||
const result = renderBody('AT&T', []);
|
||||
expect(result).toContain('AT&T');
|
||||
});
|
||||
|
||||
it('wraps @mention in a mention span', () => {
|
||||
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||
const result = renderBody('Hey @Hans Müller!', mentions);
|
||||
expect(result).toContain('<span');
|
||||
expect(result).toContain('class="mention"');
|
||||
expect(result).toContain('Hans Müller');
|
||||
});
|
||||
|
||||
it('does not double-encode already escaped text', () => {
|
||||
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||
const result = renderBody('Check @Hans Müller', mentions);
|
||||
expect(result).not.toContain('&');
|
||||
});
|
||||
|
||||
it('replaces all occurrences of the same mention', () => {
|
||||
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||
const result = renderBody('@Hans Müller and @Hans Müller', mentions);
|
||||
const spanCount = (result.match(/<span /g) ?? []).length;
|
||||
expect(spanCount).toBe(2);
|
||||
});
|
||||
|
||||
it('escapes HTML special chars in mention display names', () => {
|
||||
const mentions: MentionDTO[] = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }];
|
||||
const result = renderBody('@<script> alert(1)', mentions);
|
||||
expect(result).not.toContain('<script>');
|
||||
expect(result).toContain('<script>');
|
||||
});
|
||||
|
||||
it('converts newlines to <br>', () => {
|
||||
const result = renderBody('line1\nline2', []);
|
||||
expect(result).toContain('<br>');
|
||||
expect(result).not.toContain('\n');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── renderTranscriptionBody ──────────────────────────────────────────────────
|
||||
|
||||
describe('renderTranscriptionBody', () => {
|
||||
const auguste: PersonMention = {
|
||||
personId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
displayName: 'Auguste Raddatz'
|
||||
};
|
||||
const hans: PersonMention = {
|
||||
personId: '550e8400-e29b-41d4-a716-446655440001',
|
||||
displayName: 'Hans'
|
||||
};
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(renderTranscriptionBody('', [])).toBe('');
|
||||
});
|
||||
|
||||
it('returns escaped plain text when no mentions', () => {
|
||||
expect(renderTranscriptionBody('Hello world', [])).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('escapes < and > in plain block text', () => {
|
||||
const result = renderTranscriptionBody('<script>alert(1)</script>', []);
|
||||
expect(result).toBe('<script>alert(1)</script>');
|
||||
expect(result).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes & in plain block text', () => {
|
||||
expect(renderTranscriptionBody('AT&T', [])).toBe('AT&T');
|
||||
});
|
||||
|
||||
it('replaces @DisplayName with anchor link to /persons/{personId}', () => {
|
||||
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
|
||||
expect(result).toContain(`<a href="/persons/${auguste.personId}"`);
|
||||
expect(result).toContain('class="person-mention"');
|
||||
expect(result).toContain(`data-person-id="${auguste.personId}"`);
|
||||
expect(result).toContain('>Auguste Raddatz</a>');
|
||||
});
|
||||
|
||||
it('strips the @ prefix from rendered link text (read mode)', () => {
|
||||
const result = renderTranscriptionBody('Hallo @Auguste Raddatz!', [auguste]);
|
||||
// The anchor body is the bare display name — no leading @
|
||||
expect(result).not.toMatch(/>@Auguste Raddatz</);
|
||||
expect(result).toMatch(/>Auguste Raddatz</);
|
||||
});
|
||||
|
||||
it('removes the trigger @ from the surrounding text (no orphan @ before the link)', () => {
|
||||
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', [auguste]);
|
||||
// No bare @ remains where the mention was
|
||||
expect(result).not.toMatch(/@<a/);
|
||||
});
|
||||
|
||||
it('replaces all occurrences of the same mention', () => {
|
||||
const result = renderTranscriptionBody('@Auguste Raddatz und @Auguste Raddatz', [auguste]);
|
||||
const anchorCount = (result.match(/<a /g) ?? []).length;
|
||||
expect(anchorCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not replace plain-text occurrences without the @ trigger', () => {
|
||||
const result = renderTranscriptionBody('Auguste Raddatz war hier', [auguste]);
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).toBe('Auguste Raddatz war hier');
|
||||
});
|
||||
|
||||
it('processes longer displayNames first to avoid prefix shadowing', () => {
|
||||
const SHORT_ID = '11111111-1111-4111-8111-111111111111';
|
||||
const LONG_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const augusteShort: PersonMention = { personId: SHORT_ID, displayName: 'Auguste' };
|
||||
const augusteLong: PersonMention = {
|
||||
personId: LONG_ID,
|
||||
displayName: 'Auguste Raddatz'
|
||||
};
|
||||
// Sidecar order is short-first; longer match must still win for the long text
|
||||
const result = renderTranscriptionBody('@Auguste Raddatz schreibt @Auguste', [
|
||||
augusteShort,
|
||||
augusteLong
|
||||
]);
|
||||
expect(result).toContain(`href="/persons/${LONG_ID}"`);
|
||||
expect(result).toContain(`href="/persons/${SHORT_ID}"`);
|
||||
// The "Raddatz" suffix must not leak inside the short-name anchor
|
||||
expect(result).not.toMatch(/>Auguste<\/a> Raddatz/);
|
||||
});
|
||||
|
||||
it('does not match @ followed by extra word characters (word boundary)', () => {
|
||||
// Sidecar contains "Hans"; text contains "@HansMüller" — no link.
|
||||
const result = renderTranscriptionBody('Brief an @HansMüller', [hans]);
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).toContain('@HansM');
|
||||
});
|
||||
|
||||
it('first-sidecar-wins when two entries share the same displayName', () => {
|
||||
// Two persons named "Hans" — first sidecar entry wins for all occurrences.
|
||||
const FIRST_ID = '33333333-3333-4333-8333-333333333333';
|
||||
const SECOND_ID = '44444444-4444-4444-8444-444444444444';
|
||||
const hansFirst: PersonMention = { personId: FIRST_ID, displayName: 'Hans' };
|
||||
const hansSecond: PersonMention = { personId: SECOND_ID, displayName: 'Hans' };
|
||||
const result = renderTranscriptionBody('@Hans und @Hans', [hansFirst, hansSecond]);
|
||||
expect(result).toContain(`href="/persons/${FIRST_ID}"`);
|
||||
expect(result).not.toContain(`href="/persons/${SECOND_ID}"`);
|
||||
const anchorCount = (result.match(/<a /g) ?? []).length;
|
||||
expect(anchorCount).toBe(2);
|
||||
});
|
||||
|
||||
it('escapes HTML in displayName to prevent stored XSS', () => {
|
||||
const xss: PersonMention = {
|
||||
personId: '55555555-5555-4555-8555-555555555555',
|
||||
displayName: '<script>alert(1)</script>'
|
||||
};
|
||||
const result = renderTranscriptionBody('Hi @<script>alert(1)</script> there', [xss]);
|
||||
expect(result).not.toContain('<script>');
|
||||
expect(result).toContain('<script>');
|
||||
});
|
||||
|
||||
it('escapes <img onerror=...> payloads in surrounding block text', () => {
|
||||
const result = renderTranscriptionBody('<img src=x onerror=alert(1)> hello', []);
|
||||
expect(result).not.toContain('<img');
|
||||
expect(result).toContain('<img');
|
||||
});
|
||||
|
||||
it('does not double-encode HTML-entity-already-encoded payloads', () => {
|
||||
// `&lt;script&gt;` is already-escaped HTML in the source text.
|
||||
// renderTranscriptionBody must escape the literal & once → `&amp;lt;...`
|
||||
// — never silently decode pre-escaped entities.
|
||||
const result = renderTranscriptionBody('text &lt;script&gt;', []);
|
||||
expect(result).toBe('text &amp;lt;script&amp;gt;');
|
||||
});
|
||||
|
||||
it('escapes quotes in displayName so they cannot break the href attribute', () => {
|
||||
const tricky: PersonMention = {
|
||||
personId: '66666666-6666-4666-8666-666666666666',
|
||||
displayName: 'O"Brien'
|
||||
};
|
||||
const result = renderTranscriptionBody('@O"Brien', [tricky]);
|
||||
// The raw `"` from the displayName must never appear inside the rendered link
|
||||
// — it would terminate the attribute value early and let an attacker craft
|
||||
// arbitrary attributes on the anchor. It must arrive at the browser as ".
|
||||
expect(result).toMatch(/>O"Brien<\/a>/);
|
||||
expect(result).not.toMatch(/>O"Brien<\/a>/);
|
||||
});
|
||||
|
||||
it('renders nothing when mentionedPersons is undefined-empty and no @ triggers', () => {
|
||||
const result = renderTranscriptionBody('Plain old transcription text.', []);
|
||||
expect(result).toBe('Plain old transcription text.');
|
||||
});
|
||||
|
||||
it('skips substitution when personId is not a UUID (defense in depth)', () => {
|
||||
// Nora #5551: if personId ever flowed in from a less-sanitised source
|
||||
// (a future "external person" or a bad sidecar), the renderer must not
|
||||
// emit a clickable link. The escaped text remains as plain content.
|
||||
const evil: PersonMention = {
|
||||
personId: 'javascript:alert(1)',
|
||||
displayName: 'Evil Link'
|
||||
};
|
||||
const result = renderTranscriptionBody('Hi @Evil Link!', [evil]);
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).not.toContain('javascript:');
|
||||
// The @-trigger and displayName are preserved as plain text
|
||||
expect(result).toContain('@Evil Link');
|
||||
});
|
||||
|
||||
it('skips substitution when personId is an absolute URL', () => {
|
||||
const evil: PersonMention = {
|
||||
personId: 'https://evil.example/persons/abc',
|
||||
displayName: 'Phisher'
|
||||
};
|
||||
const result = renderTranscriptionBody('Hi @Phisher', [evil]);
|
||||
expect(result).not.toContain('<a ');
|
||||
expect(result).not.toContain('https://evil.example');
|
||||
});
|
||||
|
||||
it('still substitutes when personId is a well-formed UUID', () => {
|
||||
// Sanity check that the validation does not over-reject valid IDs.
|
||||
const valid: PersonMention = {
|
||||
personId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
displayName: 'Auguste Raddatz'
|
||||
};
|
||||
const result = renderTranscriptionBody('Brief an @Auguste Raddatz', [valid]);
|
||||
expect(result).toContain('<a ');
|
||||
expect(result).toContain('href="/persons/550e8400-e29b-41d4-a716-446655440000"');
|
||||
});
|
||||
});
|
||||
163
frontend/src/lib/shared/discussion/mention.ts
Normal file
163
frontend/src/lib/shared/discussion/mention.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { MentionDTO, PersonMention } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Single-source CSS selector for rendered person-mention anchors. Used by:
|
||||
* - layout.css (.person-mention rule, focus ring, underline)
|
||||
* - TranscriptionReadView (delegated mouseenter/leave/click handlers)
|
||||
* - unit + e2e tests
|
||||
*
|
||||
* Keep these in sync — the renderer template below emits exactly this class.
|
||||
*/
|
||||
export const PERSON_MENTION_SELECTOR = 'a.person-mention';
|
||||
|
||||
/**
|
||||
* Branded string type for HTML that has been pre-escaped and assembled by
|
||||
* one of the trusted renderers in this module. The brand exists so that
|
||||
* `{@html …}` consumers can require a SafeHtml input at compile time —
|
||||
* `{@html block.text}` won't typecheck unless the string came through
|
||||
* a renderer that escapes its inputs.
|
||||
*
|
||||
* Defense in depth against stored XSS (Sina #5505 / Nora PR-B2 review).
|
||||
*/
|
||||
export type SafeHtml = string & { readonly __brand: 'SafeHtml' };
|
||||
|
||||
/**
|
||||
* Given the current textarea value and cursor position, returns the
|
||||
* @-mention query being typed (the text after the last triggering @),
|
||||
* or null if no mention is active.
|
||||
*
|
||||
* Rules:
|
||||
* - @ must be preceded by whitespace or be at the start of the string
|
||||
* - The text between @ and the cursor must not contain a space (a
|
||||
* completed mention word already has a space)
|
||||
*/
|
||||
export function detectMention(text: string, cursorPos: number): string | null {
|
||||
const before = text.slice(0, cursorPos);
|
||||
const atIndex = before.lastIndexOf('@');
|
||||
if (atIndex === -1) return null;
|
||||
|
||||
// @ must be at start or preceded by whitespace
|
||||
if (atIndex > 0 && !/\s/.test(before[atIndex - 1])) return null;
|
||||
|
||||
const query = before.slice(atIndex + 1);
|
||||
// If the query contains a space the user has moved past the trigger word
|
||||
if (query.includes(' ')) return null;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the raw textarea value and a list of candidate users (from the
|
||||
* mention popup selections), returns the plain content string and the
|
||||
* de-duplicated list of mentioned user IDs.
|
||||
*/
|
||||
export function extractContent(
|
||||
text: string,
|
||||
candidates: MentionDTO[]
|
||||
): { content: string; mentionedUserIds: string[] } {
|
||||
const seen = new Set<string>();
|
||||
for (const user of candidates) {
|
||||
const displayName = `${user.firstName} ${user.lastName}`.trim();
|
||||
if (text.includes(`@${displayName}`)) {
|
||||
seen.add(user.id);
|
||||
}
|
||||
}
|
||||
return { content: text, mentionedUserIds: [...seen] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes the five HTML-special characters that can break out of text content
|
||||
* or attribute values. & must be escaped first to avoid double-encoding.
|
||||
*
|
||||
* Includes the apostrophe so the helper is safe in single-quoted attribute
|
||||
* values too — the renderTranscriptionBody anchor template in PR-B2 uses
|
||||
* double quotes today, but a future template change shouldn't open a
|
||||
* stored-XSS hole (Sina #5505 action item).
|
||||
*/
|
||||
export function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict UUID v1–v5 check. Used as a defensive boundary on PersonMention.personId
|
||||
* before substituting it into an `href` — even though the backend currently only
|
||||
* emits UUIDs, a future "external person" feature must not accidentally turn this
|
||||
* helper into an open-redirect surface (CWE-601).
|
||||
*/
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function isUuid(value: string): boolean {
|
||||
return UUID_RE.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a transcription block's text segment as safe HTML for read mode.
|
||||
*
|
||||
* Rules:
|
||||
* 1. The full text is HTML-escaped first (defense against stored XSS).
|
||||
* 2. For each entry in `mentionedPersons`, every `@DisplayName` occurrence is
|
||||
* replaced with `<a href="/persons/{personId}" class="person-mention" …>DisplayName</a>`.
|
||||
* The `@` prefix is stripped from the rendered link text — it is an editor
|
||||
* affordance, not part of the historical text (issue #362).
|
||||
* 3. Longest displayNames are processed first so a short prefix in the sidecar
|
||||
* cannot shadow a longer match in the text (e.g. `@Auguste` vs `@Auguste Raddatz`).
|
||||
* 4. Word-boundary lookahead prevents `@Hans` from matching `@HansMüller`.
|
||||
* 5. First-sidecar-wins for entries that share a displayName (deterministic
|
||||
* rule per Felix decision OQ-1, comment #5339).
|
||||
*/
|
||||
export function renderTranscriptionBody(text: string, mentionedPersons: PersonMention[]): SafeHtml {
|
||||
if (!text) return '' as SafeHtml;
|
||||
let escaped = escapeHtml(text);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const unique: PersonMention[] = [];
|
||||
for (const mention of mentionedPersons) {
|
||||
if (seen.has(mention.displayName)) continue;
|
||||
// Defense in depth: refuse to render an anchor for a non-UUID personId.
|
||||
// The escaped block text falls through unchanged, so the @-trigger is
|
||||
// preserved as plain content — no silent data loss, no clickable link.
|
||||
if (!isUuid(mention.personId)) continue;
|
||||
seen.add(mention.displayName);
|
||||
unique.push(mention);
|
||||
}
|
||||
|
||||
const sorted = [...unique].sort((a, b) => b.displayName.length - a.displayName.length);
|
||||
|
||||
for (const mention of sorted) {
|
||||
const escapedDisplayName = escapeHtml(mention.displayName);
|
||||
const escapedPersonId = escapeHtml(mention.personId);
|
||||
const pattern = new RegExp(`@${escapeRegExp(escapedDisplayName)}(?![\\p{L}\\p{N}])`, 'gu');
|
||||
const link = `<a href="/persons/${escapedPersonId}" class="person-mention" data-person-id="${escapedPersonId}">${escapedDisplayName}</a>`;
|
||||
escaped = escaped.replace(pattern, link);
|
||||
}
|
||||
|
||||
return escaped as SafeHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a comment body as safe HTML:
|
||||
* 1. Escapes all HTML-special characters in the raw content
|
||||
* 2. Replaces every @FirstName LastName occurrence with an anchor link
|
||||
* 3. Converts newlines to <br>
|
||||
*/
|
||||
export function renderBody(content: string, mentions: MentionDTO[]): SafeHtml {
|
||||
let escaped = escapeHtml(content);
|
||||
|
||||
for (const mention of mentions) {
|
||||
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
|
||||
const escapedDisplayName = escapeHtml(displayName);
|
||||
const span = `<span class="mention" data-user-id="${mention.id}">@${escapedDisplayName}</span>`;
|
||||
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
|
||||
}
|
||||
|
||||
return escaped.replaceAll('\n', '<br>') as SafeHtml;
|
||||
}
|
||||
138
frontend/src/lib/shared/discussion/mentionSerializer.spec.ts
Normal file
138
frontend/src/lib/shared/discussion/mentionSerializer.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deserialize, serialize } from './mentionSerializer';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
|
||||
// ─── deserialize ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deserialize', () => {
|
||||
it('returns a valid Tiptap doc for an empty string', () => {
|
||||
const doc = deserialize('', []);
|
||||
expect(doc.type).toBe('doc');
|
||||
expect(doc.content).toHaveLength(1);
|
||||
expect(doc.content![0].type).toBe('paragraph');
|
||||
});
|
||||
|
||||
it('returns plain text node for text with no mentions', () => {
|
||||
const doc = deserialize('Hallo Welt', []);
|
||||
const para = doc.content![0];
|
||||
expect(para.content).toHaveLength(1);
|
||||
expect(para.content![0]).toEqual({ type: 'text', text: 'Hallo Welt' });
|
||||
});
|
||||
|
||||
it('places a mention node at the correct position in the paragraph', () => {
|
||||
const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }];
|
||||
const doc = deserialize('Hallo @Clara Welt', sidecar);
|
||||
const nodes = doc.content![0].content!;
|
||||
expect(nodes).toHaveLength(3);
|
||||
expect(nodes[0]).toEqual({ type: 'text', text: 'Hallo ' });
|
||||
expect(nodes[1]).toMatchObject({
|
||||
type: 'mention',
|
||||
attrs: { displayName: 'Clara', personId: 'uuid-x' }
|
||||
});
|
||||
expect(nodes[2]).toEqual({ type: 'text', text: ' Welt' });
|
||||
});
|
||||
|
||||
it('prefers the longer displayName when two sidecar entries share a prefix', () => {
|
||||
const sidecar: PersonMention[] = [
|
||||
{ personId: 'uuid-short', displayName: 'Auguste' },
|
||||
{ personId: 'uuid-long', displayName: 'Auguste Raddatz' }
|
||||
];
|
||||
const doc = deserialize('@Auguste Raddatz', sidecar);
|
||||
const nodes = doc.content![0].content!;
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0]).toMatchObject({
|
||||
type: 'mention',
|
||||
attrs: { personId: 'uuid-long', displayName: 'Auguste Raddatz' }
|
||||
});
|
||||
});
|
||||
|
||||
it('splits multiple paragraphs on newline', () => {
|
||||
const doc = deserialize('Zeile 1\nZeile 2', []);
|
||||
expect(doc.content).toHaveLength(2);
|
||||
expect(doc.content![0].content![0]).toEqual({ type: 'text', text: 'Zeile 1' });
|
||||
expect(doc.content![1].content![0]).toEqual({ type: 'text', text: 'Zeile 2' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── serialize ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('serialize', () => {
|
||||
it('serializes plain text unchanged', () => {
|
||||
const doc = deserialize('Hallo Welt', []);
|
||||
const { text, mentionedPersons } = serialize(doc);
|
||||
expect(text).toBe('Hallo Welt');
|
||||
expect(mentionedPersons).toEqual([]);
|
||||
});
|
||||
|
||||
it('serializes mention node back to @displayName', () => {
|
||||
const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }];
|
||||
const doc = deserialize('Hallo @Clara Welt', sidecar);
|
||||
const { text, mentionedPersons } = serialize(doc);
|
||||
expect(text).toBe('Hallo @Clara Welt');
|
||||
expect(mentionedPersons).toEqual([{ personId: 'uuid-x', displayName: 'Clara' }]);
|
||||
});
|
||||
|
||||
it('joins multi-paragraph doc back with newlines', () => {
|
||||
const doc = deserialize('Zeile 1\nZeile 2', []);
|
||||
const { text } = serialize(doc);
|
||||
expect(text).toBe('Zeile 1\nZeile 2');
|
||||
});
|
||||
|
||||
it('de-duplicates repeated mentions in the sidecar', () => {
|
||||
const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }];
|
||||
const doc = deserialize('@Clara und @Clara', sidecar);
|
||||
const { mentionedPersons } = serialize(doc);
|
||||
expect(mentionedPersons).toHaveLength(1);
|
||||
expect(mentionedPersons[0].personId).toBe('uuid-x');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Round-trip invariant ─────────────────────────────────────────────────────
|
||||
|
||||
describe('round-trip invariant', () => {
|
||||
it('text is preserved exactly through deserialize → serialize', () => {
|
||||
const cases = [
|
||||
['', []],
|
||||
['Hallo Welt', []],
|
||||
['@Clara schreibt', [{ personId: 'uuid-x', displayName: 'Clara' }]],
|
||||
['Zeile 1\nZeile 2', []],
|
||||
['Sehr geehrte @Auguste Raddatz,', [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }]]
|
||||
] as const;
|
||||
|
||||
for (const [text, sidecar] of cases) {
|
||||
const doc = deserialize(text, sidecar as PersonMention[]);
|
||||
const { text: out } = serialize(doc);
|
||||
expect(out, `round-trip failed for: "${text}"`).toBe(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Backward compatibility (AC-6) ────────────────────────────────────────────
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('old-format full-name sidecar entry still round-trips correctly', () => {
|
||||
// Before this issue, displayName stored the person's full DB name.
|
||||
// renderTranscriptionBody already handles this — so does the serializer.
|
||||
const oldSidecar: PersonMention[] = [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }];
|
||||
const text = 'Brief von @Auguste Raddatz';
|
||||
const doc = deserialize(text, oldSidecar);
|
||||
const { text: out, mentionedPersons } = serialize(doc);
|
||||
expect(out).toBe(text);
|
||||
expect(mentionedPersons).toEqual(oldSidecar);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Security ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('security', () => {
|
||||
it('displayName containing HTML special chars is preserved as a string, not injected', () => {
|
||||
const sidecar: PersonMention[] = [
|
||||
{ personId: 'uuid-x', displayName: '<script>alert(1)</script>' }
|
||||
];
|
||||
const text = '@<script>alert(1)</script>';
|
||||
const doc = deserialize(text, sidecar);
|
||||
const { mentionedPersons } = serialize(doc);
|
||||
// The displayName is stored verbatim — HTML escaping is the renderer's job
|
||||
expect(mentionedPersons[0].displayName).toBe('<script>alert(1)</script>');
|
||||
});
|
||||
});
|
||||
113
frontend/src/lib/shared/discussion/mentionSerializer.ts
Normal file
113
frontend/src/lib/shared/discussion/mentionSerializer.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { JSONContent } from '@tiptap/core';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
|
||||
/**
|
||||
* Converts stored block text + sidecar into a Tiptap ProseMirror document.
|
||||
*
|
||||
* The text is split by "\n" into paragraphs. Within each paragraph, sidecar
|
||||
* entries are matched against "@displayName" tokens (longest first) and
|
||||
* converted to mention nodes. Unmatched text becomes plain text nodes.
|
||||
*
|
||||
* Round-trip invariant: serialize(deserialize(text, sidecar)).text === text
|
||||
*/
|
||||
export function deserialize(text: string, sidecar: PersonMention[]): JSONContent {
|
||||
const lines = text === '' ? [''] : text.split('\n');
|
||||
|
||||
// Sort sidecar by displayName length descending so longer names shadow
|
||||
// shorter prefix matches (same heuristic as renderTranscriptionBody).
|
||||
const sorted = [...sidecar].sort((a, b) => b.displayName.length - a.displayName.length);
|
||||
|
||||
return {
|
||||
type: 'doc',
|
||||
content: lines.map((line) => ({
|
||||
type: 'paragraph',
|
||||
content: parseLine(line, sorted)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function parseLine(text: string, sidecar: PersonMention[]): JSONContent[] {
|
||||
if (text === '') return [];
|
||||
|
||||
if (sidecar.length === 0) {
|
||||
return [{ type: 'text', text }];
|
||||
}
|
||||
|
||||
// Build a list of mention ranges: { start, end, mention }
|
||||
const ranges: Array<{ start: number; end: number; mention: PersonMention }> = [];
|
||||
|
||||
for (const mention of sidecar) {
|
||||
const needle = `@${mention.displayName}`;
|
||||
let idx = 0;
|
||||
while (idx < text.length) {
|
||||
const pos = text.indexOf(needle, idx);
|
||||
if (pos === -1) break;
|
||||
// Check that the range doesn't overlap an already-found range
|
||||
const end = pos + needle.length;
|
||||
const overlaps = ranges.some((r) => r.start < end && r.end > pos);
|
||||
if (!overlaps) {
|
||||
ranges.push({ start: pos, end, mention });
|
||||
}
|
||||
idx = pos + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (ranges.length === 0) {
|
||||
return [{ type: 'text', text }];
|
||||
}
|
||||
|
||||
// Sort by position
|
||||
ranges.sort((a, b) => a.start - b.start);
|
||||
|
||||
const nodes: JSONContent[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (const { start, end, mention } of ranges) {
|
||||
if (start > cursor) {
|
||||
nodes.push({ type: 'text', text: text.slice(cursor, start) });
|
||||
}
|
||||
nodes.push({
|
||||
type: 'mention',
|
||||
attrs: { displayName: mention.displayName, personId: mention.personId }
|
||||
});
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
nodes.push({ type: 'text', text: text.slice(cursor) });
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Tiptap ProseMirror document back to stored block text + sidecar.
|
||||
*
|
||||
* Paragraphs are joined with "\n". Mention nodes are emitted as "@displayName"
|
||||
* and collected into mentionedPersons (de-duplicated by personId).
|
||||
*/
|
||||
export function serialize(doc: JSONContent): { text: string; mentionedPersons: PersonMention[] } {
|
||||
const paragraphs = doc.content ?? [];
|
||||
const mentionedPersons: PersonMention[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const para of paragraphs) {
|
||||
let line = '';
|
||||
for (const node of para.content ?? []) {
|
||||
if (node.type === 'text') {
|
||||
line += node.text ?? '';
|
||||
} else if (node.type === 'mention') {
|
||||
const { displayName, personId } = node.attrs ?? {};
|
||||
line += `@${displayName}`;
|
||||
if (!seenIds.has(personId)) {
|
||||
seenIds.add(personId);
|
||||
mentionedPersons.push({ personId, displayName });
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
return { text: lines.join('\n'), mentionedPersons };
|
||||
}
|
||||
Reference in New Issue
Block a user