@@ -2,6 +2,9 @@
|
|||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import type { Comment, CommentReply } from '$lib/types';
|
import type { Comment, CommentReply } from '$lib/types';
|
||||||
|
import MentionEditor from '$lib/components/MentionEditor.svelte';
|
||||||
|
import { renderBody, extractContent } from '$lib/utils/mention';
|
||||||
|
import type { MentionDTO } from '$lib/types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -32,6 +35,9 @@ let replyText: string = $state('');
|
|||||||
let editingId: string | null = $state(null);
|
let editingId: string | null = $state(null);
|
||||||
let editText: string = $state('');
|
let editText: string = $state('');
|
||||||
let posting: boolean = $state(false);
|
let posting: boolean = $state(false);
|
||||||
|
let newMentionCandidates: MentionDTO[] = $state([]);
|
||||||
|
let replyMentionCandidates: MentionDTO[] = $state([]);
|
||||||
|
let editMentionCandidates: MentionDTO[] = $state([]);
|
||||||
|
|
||||||
const commentsBase = $derived(
|
const commentsBase = $derived(
|
||||||
annotationId
|
annotationId
|
||||||
@@ -76,13 +82,15 @@ async function postComment() {
|
|||||||
if (!text || posting) return;
|
if (!text || posting) return;
|
||||||
posting = true;
|
posting = true;
|
||||||
try {
|
try {
|
||||||
|
const { content, mentionedUserIds } = extractContent(text, newMentionCandidates);
|
||||||
const res = await fetch(commentsBase, {
|
const res = await fetch(commentsBase, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: text })
|
body: JSON.stringify({ content, mentionedUserIds })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
newText = '';
|
newText = '';
|
||||||
|
newMentionCandidates = [];
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -95,13 +103,15 @@ async function postReply(threadId: string) {
|
|||||||
if (!text || posting) return;
|
if (!text || posting) return;
|
||||||
posting = true;
|
posting = true;
|
||||||
try {
|
try {
|
||||||
|
const { content, mentionedUserIds } = extractContent(text, replyMentionCandidates);
|
||||||
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
|
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: text })
|
body: JSON.stringify({ content, mentionedUserIds })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
replyText = '';
|
replyText = '';
|
||||||
|
replyMentionCandidates = [];
|
||||||
replyingTo = null;
|
replyingTo = null;
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
@@ -115,13 +125,15 @@ async function saveEdit(commentId: string) {
|
|||||||
if (!text || posting) return;
|
if (!text || posting) return;
|
||||||
posting = true;
|
posting = true;
|
||||||
try {
|
try {
|
||||||
|
const { content, mentionedUserIds } = extractContent(text, editMentionCandidates);
|
||||||
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: text })
|
body: JSON.stringify({ content, mentionedUserIds })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
editingId = null;
|
editingId = null;
|
||||||
|
editMentionCandidates = [];
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -147,6 +159,7 @@ async function deleteComment(commentId: string) {
|
|||||||
function startEdit(comment: Comment | CommentReply) {
|
function startEdit(comment: Comment | CommentReply) {
|
||||||
editingId = comment.id;
|
editingId = comment.id;
|
||||||
editText = comment.content;
|
editText = comment.content;
|
||||||
|
editMentionCandidates = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
@@ -181,11 +194,13 @@ onMount(() => {
|
|||||||
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
|
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
|
||||||
{#if editingId === comment.id}
|
{#if editingId === comment.id}
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<textarea
|
<MentionEditor
|
||||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
|
||||||
rows={3}
|
|
||||||
bind:value={editText}
|
bind:value={editText}
|
||||||
></textarea>
|
bind:mentionCandidates={editMentionCandidates}
|
||||||
|
rows={3}
|
||||||
|
disabled={posting}
|
||||||
|
onsubmit={() => saveEdit(comment.id)}
|
||||||
|
/>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||||
@@ -215,7 +230,10 @@ onMount(() => {
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">{comment.content}</p>
|
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
|
||||||
|
{@html renderBody(comment.content, comment.mentionDTOs ?? [])}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{#if canModify(comment)}
|
{#if canModify(comment)}
|
||||||
<div class="flex shrink-0 items-center gap-2">
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
@@ -283,12 +301,14 @@ onMount(() => {
|
|||||||
<!-- Reply compose box -->
|
<!-- Reply compose box -->
|
||||||
{#if replyingTo === thread.id}
|
{#if replyingTo === thread.id}
|
||||||
<div class="mt-3 ml-6 flex flex-col gap-2">
|
<div class="mt-3 ml-6 flex flex-col gap-2">
|
||||||
<textarea
|
<MentionEditor
|
||||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
bind:value={replyText}
|
||||||
|
bind:mentionCandidates={replyMentionCandidates}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={m.comment_placeholder()}
|
placeholder={m.comment_placeholder()}
|
||||||
bind:value={replyText}
|
disabled={posting}
|
||||||
></textarea>
|
onsubmit={() => postReply(thread.id)}
|
||||||
|
/>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||||
@@ -313,12 +333,14 @@ onMount(() => {
|
|||||||
{#if canComment}
|
{#if canComment}
|
||||||
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
|
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<textarea
|
<MentionEditor
|
||||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
bind:value={newText}
|
||||||
|
bind:mentionCandidates={newMentionCandidates}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={m.comment_placeholder()}
|
placeholder={m.comment_placeholder()}
|
||||||
bind:value={newText}
|
disabled={posting}
|
||||||
></textarea>
|
onsubmit={postComment}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||||
|
|||||||
235
frontend/src/lib/components/MentionEditor.svelte
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
|
|||||||
|
import { detectMention } from '$lib/utils/mention';
|
||||||
|
import type { MentionDTO } from '$lib/types';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
mentionCandidates: MentionDTO[];
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
onsubmit?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
mentionCandidates = $bindable([]),
|
||||||
|
placeholder = '',
|
||||||
|
rows = 3,
|
||||||
|
disabled = false,
|
||||||
|
onsubmit
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let query: string | null = $state(null);
|
||||||
|
let results: MentionDTO[] = $state([]);
|
||||||
|
let highlightedIndex = $state(0);
|
||||||
|
let mentionStart = $state(0);
|
||||||
|
|
||||||
|
let textarea: HTMLTextAreaElement | null = null;
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
function attachTextarea(node: HTMLTextAreaElement) {
|
||||||
|
textarea = node;
|
||||||
|
return () => {
|
||||||
|
textarea = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
if (!textarea) return;
|
||||||
|
const cursorPos = textarea.selectionStart;
|
||||||
|
const detected = detectMention(value, cursorPos);
|
||||||
|
|
||||||
|
if (detected === null) {
|
||||||
|
closePopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate where the @ starts
|
||||||
|
const before = value.slice(0, cursorPos);
|
||||||
|
const atIndex = before.lastIndexOf('@');
|
||||||
|
mentionStart = atIndex;
|
||||||
|
|
||||||
|
if (query !== detected) {
|
||||||
|
query = detected;
|
||||||
|
highlightedIndex = 0;
|
||||||
|
scheduleSearch(detected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSearch(q: string) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
if (!q) {
|
||||||
|
results = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data: MentionDTO[] = await res.json();
|
||||||
|
results = data.slice(0, 5);
|
||||||
|
} else {
|
||||||
|
results = [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
results = [];
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectUser(user: MentionDTO) {
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const displayName = `${user.firstName} ${user.lastName}`;
|
||||||
|
// Replace @partialQuery with @FirstName LastName (plus trailing space)
|
||||||
|
const replacement = `@${displayName} `;
|
||||||
|
const cursorPos = textarea.selectionStart;
|
||||||
|
const before = value.slice(0, mentionStart);
|
||||||
|
const after = value.slice(cursorPos);
|
||||||
|
value = before + replacement + after;
|
||||||
|
|
||||||
|
// Deduplicate and add to candidates
|
||||||
|
if (!mentionCandidates.some((c) => c.id === user.id)) {
|
||||||
|
mentionCandidates = [...mentionCandidates, user];
|
||||||
|
}
|
||||||
|
|
||||||
|
closePopup();
|
||||||
|
|
||||||
|
// Reposition cursor after the inserted mention
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!textarea) return;
|
||||||
|
const pos = mentionStart + replacement.length;
|
||||||
|
textarea.selectionStart = pos;
|
||||||
|
textarea.selectionEnd = pos;
|
||||||
|
textarea.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePopup() {
|
||||||
|
query = null;
|
||||||
|
results = [];
|
||||||
|
highlightedIndex = 0;
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.ctrlKey && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
onsubmit?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query === null) return;
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closePopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (results.length > 0) {
|
||||||
|
highlightedIndex = (highlightedIndex + 1) % results.length;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (results.length > 0) {
|
||||||
|
highlightedIndex = (highlightedIndex - 1 + results.length) % results.length;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && results.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectUser(results[highlightedIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAtButtonClick() {
|
||||||
|
if (!textarea) return;
|
||||||
|
const pos = textarea.selectionStart;
|
||||||
|
const before = value.slice(0, pos);
|
||||||
|
const after = value.slice(pos);
|
||||||
|
// Ensure @ is preceded by whitespace or is at the start
|
||||||
|
const needsSpace = before.length > 0 && !/\s$/.test(before);
|
||||||
|
const insertion = needsSpace ? ' @' : '@';
|
||||||
|
value = before + insertion + after;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!textarea) return;
|
||||||
|
const newPos = pos + insertion.length;
|
||||||
|
textarea.selectionStart = newPos;
|
||||||
|
textarea.selectionEnd = newPos;
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
// Trigger mention detection after inserting @
|
||||||
|
const detected = detectMention(value, newPos);
|
||||||
|
if (detected !== null) {
|
||||||
|
mentionStart = newPos - 1;
|
||||||
|
query = detected;
|
||||||
|
highlightedIndex = 0;
|
||||||
|
scheduleSearch(detected);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const popupOpen = $derived(query !== null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<textarea
|
||||||
|
{@attach attachTextarea}
|
||||||
|
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||||
|
rows={rows}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
bind:value={value}
|
||||||
|
oninput={handleInput}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
{#if popupOpen}
|
||||||
|
<div
|
||||||
|
class="absolute z-20 mt-1 w-64 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={m.mention_btn_label()}
|
||||||
|
>
|
||||||
|
{#if results.length === 0}
|
||||||
|
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.mention_popup_empty()}</p>
|
||||||
|
{:else}
|
||||||
|
{#each results as user, i (user.id)}
|
||||||
|
<button
|
||||||
|
class="w-full px-3 py-2 text-left font-sans text-sm text-ink hover:bg-canvas {i === highlightedIndex ? 'bg-canvas' : ''}"
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === highlightedIndex}
|
||||||
|
onmousedown={(e) => {
|
||||||
|
// Use mousedown to fire before textarea blur
|
||||||
|
e.preventDefault();
|
||||||
|
selectUser(user);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.firstName}
|
||||||
|
{user.lastName}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={m.mention_btn_label()}
|
||||||
|
disabled={disabled}
|
||||||
|
class="mt-1 rounded border border-line px-2 py-0.5 font-sans text-xs font-medium text-ink-3 transition-colors hover:border-ink hover:text-ink disabled:opacity-40"
|
||||||
|
onclick={handleAtButtonClick}
|
||||||
|
>
|
||||||
|
@
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
export type MentionDTO = {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CommentReply = {
|
export type CommentReply = {
|
||||||
id: string;
|
id: string;
|
||||||
authorId: string | null;
|
authorId: string | null;
|
||||||
@@ -5,6 +11,7 @@ export type CommentReply = {
|
|||||||
content: string;
|
content: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
mentionDTOs?: MentionDTO[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Comment = {
|
export type Comment = {
|
||||||
@@ -15,6 +22,7 @@ export type Comment = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
replies: CommentReply[];
|
replies: CommentReply[];
|
||||||
|
mentionDTOs?: MentionDTO[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||||
|
|||||||
120
frontend/src/lib/utils/mention.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
marcel
commented
🔵 MINOR — XSS coverage missing in The spec covers HTML escaping of the comment body content, but has no test where a Fix: Add a test case: 🔵 **MINOR — XSS coverage missing in `renderBody` test suite**
The spec covers HTML escaping of the comment body content, but has no test where a `mention.firstName` or `mention.lastName` contains HTML-special characters. This means the XSS vector identified in `renderBody` (Finding #9 above) would not be caught by CI even after the fix is applied — leaving the regression unguarded.
**Fix:** Add a test case:
```typescript
it('should escape HTML in mention display names', () => {
const body = '@<script>';
const mentions = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }];
const html = renderBody(body, mentions);
expect(html).not.toContain('<script>');
expect(html).toContain('<script>');
});
```
|
|||||||
|
import { detectMention, extractContent, renderBody } from './mention';
|
||||||
|
import type { MentionDTO } from '$lib/types';
|
||||||
|
|
||||||
|
// ─── 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 an anchor tag', () => {
|
||||||
|
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||||
|
const result = renderBody('Hey @Hans Müller!', mentions);
|
||||||
|
expect(result).toContain('<a');
|
||||||
|
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 linkCount = (result.match(/<a /g) ?? []).length;
|
||||||
|
expect(linkCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts newlines to <br>', () => {
|
||||||
|
const result = renderBody('line1\nline2', []);
|
||||||
|
expect(result).toContain('<br>');
|
||||||
|
expect(result).not.toContain('\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
67
frontend/src/lib/utils/mention.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { MentionDTO } from '$lib/types';
|
||||||
|
marcel
commented
⚠️ MAJOR — Stored XSS vector in The comment body text is correctly escape-first-then-process, but Fix: Apply the same HTML escape treatment to ⚠️ **MAJOR — Stored XSS vector in `renderBody`: mention display names are not escaped**
```typescript
const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`;
```
The comment body text is correctly escape-first-then-process, but `mention.firstName` and `mention.lastName` are injected into the link string without any HTML escaping. An admin who registers a user with `lastName: '"\><img src=x onerror=alert(1)>'` produces a working XSS payload rendered in every comment that mentions that user.
**Fix:** Apply the same HTML escape treatment to `displayName` before injecting it. Also add a test in `mention.spec.ts`:
```typescript
it('escapes HTML special chars in mention display name', () => {
const mentions = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }];
const result = renderBody('@<script> alert(1)', mentions);
expect(result).not.toContain('<script>');
});
```
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* 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] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[]): string {
|
||||||
|
let escaped = content
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
|
||||||
|
for (const mention of mentions) {
|
||||||
|
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
|
||||||
|
const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`;
|
||||||
|
escaped = escaped.replaceAll(`@${displayName}`, link);
|
||||||
|
}
|
||||||
|
|
||||||
|
return escaped.replaceAll('\n', '<br>');
|
||||||
|
}
|
||||||
🔵 MINOR —
debounceTimernot cleared on component destroyThe
debounceTimeris cleared insideclosePopup(), but there is noonDestroyhook. If the component unmounts while a debounced search is pending (user navigates away mid-typing), the timeout fires on an unmounted component and causes a stale state update.Fix: