diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index f1372f1..36c1c57 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -179,8 +179,8 @@ PDF, text/log, office documents, RTF or archives; an unsupported file is refused without naming the allowed types). After sending, the form clears and confirms "Ваше сообщение отправлено", and sending is blocked until the operator has dealt with that message — on re-entry the screen reads "Ожидаем рассмотрения вашего последнего обращения". The operator's reply appears -below the form as "Ответ на ваше последнее сообщение"; it is marked read once the screen shows it -and disappears a week later. A badge on the Settings tab (and on Info inside it) flags an +below the form as "Ответ на ваше последнее сообщение" (any link in it opens in a new tab); it is +marked read once the screen shows it and disappears a week later. A badge on the Settings tab (and on Info inside it) flags an unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has barred from feedback (a role, not a full account block) sees the send control disabled. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 1e05f48..2a7d38a 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -184,8 +184,8 @@ UTC), суточного окна отсутствия (away; сетка по 10 допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране «Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как -«Ответ на ваше последнее сообщение»; он помечается прочитанным, как только экран его показал, и -исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном +«Ответ на ваше последнее сообщение» (ссылки в нём открываются в новой вкладке); он помечается +прочитанным, как только экран его показал, и исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной. diff --git a/ui/e2e/feedback.spec.ts b/ui/e2e/feedback.spec.ts index 8982ce3..c4dce6c 100644 --- a/ui/e2e/feedback.spec.ts +++ b/ui/e2e/feedback.spec.ts @@ -43,7 +43,11 @@ test('feedback: an operator reply raises the badge and shows on the screen', asy await openFeedback(page); await expect(page.getByText('Reply to your last message')).toBeVisible(); - await expect(page.getByText(/looking into it/)).toBeVisible(); + // A URL in the reply renders as a link that opens in a new tab. + const link = page.getByRole('link', { name: 'https://example.com/help' }); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute('target', '_blank'); + await expect(link).toHaveAttribute('rel', /noopener/); }); test('feedback: sending a new message clears the previous reply', async ({ page }) => { diff --git a/ui/src/lib/feedback.test.ts b/ui/src/lib/feedback.test.ts index dac96a5..b1419bb 100644 --- a/ui/src/lib/feedback.test.ts +++ b/ui/src/lib/feedback.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { attachmentError, MAX_ATTACHMENT_BYTES } from './feedback'; +import { attachmentError, linkify, MAX_ATTACHMENT_BYTES } from './feedback'; describe('attachmentError', () => { it('accepts allowed extensions within the size cap', () => { @@ -22,3 +22,56 @@ describe('attachmentError', () => { expect(attachmentError('shot.png', MAX_ATTACHMENT_BYTES + 1)).toBe('size'); }); }); + +describe('linkify', () => { + it('returns a single text segment for plain text', () => { + expect(linkify('just text')).toEqual([{ text: 'just text' }]); + }); + + it('marks an http(s) URL as a link', () => { + expect(linkify('see https://example.com now')).toEqual([ + { text: 'see ' }, + { text: 'https://example.com', href: 'https://example.com' }, + { text: ' now' }, + ]); + }); + + it('keeps trailing punctuation out of the link', () => { + expect(linkify('go to https://example.com.')).toEqual([ + { text: 'go to ' }, + { text: 'https://example.com', href: 'https://example.com' }, + { text: '.' }, + ]); + expect(linkify('(see https://example.com/path)')).toEqual([ + { text: '(see ' }, + { text: 'https://example.com/path', href: 'https://example.com/path' }, + { text: ')' }, + ]); + }); + + it('handles multiple links', () => { + const segs = linkify('a http://x.io b https://y.io'); + expect(segs.filter((s) => s.href).map((s) => s.href)).toEqual(['http://x.io', 'https://y.io']); + }); + + it('linkifies whitelisted non-http schemes (ftp, mailto, tel)', () => { + expect(linkify('ftp://files.example.com/x')).toContainEqual({ + text: 'ftp://files.example.com/x', + href: 'ftp://files.example.com/x', + }); + expect(linkify('write to mailto:help@example.com please')).toContainEqual({ + text: 'mailto:help@example.com', + href: 'mailto:help@example.com', + }); + expect(linkify('call tel:+1234')).toContainEqual({ text: 'tel:+1234', href: 'tel:+1234' }); + }); + + it('never linkifies dangerous schemes', () => { + const segs = linkify('javascript:alert(1) data:text/html,x vbscript:msgbox'); + expect(segs.some((s) => s.href)).toBe(false); + }); + + it('does not match a scheme inside a word (hotel:, email:)', () => { + expect(linkify('the hotel:foo and email:bar').some((s) => s.href)).toBe(false); + }); +}); diff --git a/ui/src/lib/feedback.ts b/ui/src/lib/feedback.ts index 18bea07..e47ace0 100644 --- a/ui/src/lib/feedback.ts +++ b/ui/src/lib/feedback.ts @@ -27,3 +27,42 @@ export function attachmentError(name: string, size: number): '' | 'type' | 'size if (size > MAX_ATTACHMENT_BYTES) return 'size'; return ''; } + +/** ReplySegment is one run of the operator reply: plain text, or a link when href is set. */ +export interface ReplySegment { + text: string; + href?: string; +} + +// Links are recognised only for an explicit, whitelisted scheme, so dangerous ones +// (javascript:, data:, vbscript:) are never turned into an href. http/https/ftp use +// "scheme://host…"; mailto/tel use "scheme:rest". Matching stops at whitespace or '<'. +// The reply is operator-authored, so explicit schemes (no bare-domain guessing) are +// enough — which keeps this a few lines with no dependency. +const URL_RE = /\b(?:(?:https?|ftp):\/\/|(?:mailto|tel):)[^\s<]+/gi; +// Trailing punctuation that should not be part of the URL (kept as following text). +const TRAILING_RE = /[.,!?;:)\]}'"»]+$/; + +/** + * linkify splits the operator reply into segments, marking http(s) URLs as links so + * the UI renders them as anchors (opened in a new tab) while everything else stays + * plain, escaped text. It never emits markup — the caller renders each segment with + * the framework's escaping — so it cannot introduce XSS. + */ +export function linkify(text: string): ReplySegment[] { + const out: ReplySegment[] = []; + let last = 0; + for (const m of text.matchAll(URL_RE)) { + const start = m.index; + let url = m[0]; + const trail = TRAILING_RE.exec(url)?.[0] ?? ''; + if (trail) url = url.slice(0, url.length - trail.length); + if (start > last) out.push({ text: text.slice(last, start) }); + out.push({ text: url, href: url }); + if (trail) out.push({ text: trail }); + last = start + m[0].length; + } + if (last < text.length) out.push({ text: text.slice(last) }); + if (out.length === 0) out.push({ text }); + return out; +} diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 5970bd1..22cefa3 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -435,7 +435,7 @@ export class MockGateway implements GatewayClient { // mockAdminReply simulates an operator reply: it clears the pending message, sets the // reply and raises the badge via a live admin_reply notification. e2e hook: // window.__mock.adminReply. - mockAdminReply(body = 'Thanks — we are looking into it.'): void { + mockAdminReply(body = 'Thanks — see https://example.com/help for details.'): void { this.feedbackPending = false; this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) }; this.feedbackReplyUnread = true; diff --git a/ui/src/screens/Feedback.svelte b/ui/src/screens/Feedback.svelte index 7c69b2e..8a39455 100644 --- a/ui/src/screens/Feedback.svelte +++ b/ui/src/screens/Feedback.svelte @@ -6,7 +6,7 @@ import { gateway } from '../lib/gateway'; import { connection } from '../lib/connection.svelte'; import { clientChannel } from '../lib/channel'; - import { attachmentError, MAX_BODY } from '../lib/feedback'; + import { attachmentError, linkify, MAX_BODY } from '../lib/feedback'; import type { FeedbackState } from '../lib/model'; // The feedback screen: a message (+ optional single attachment) the player sends to @@ -23,6 +23,8 @@ const enabled = $derived(view?.canSend ?? false); const reason = $derived(view?.blockedReason ?? ''); const canSend = $derived(enabled && body.trim().length > 0 && !sending && connection.online); + // The operator reply, split into text + http(s) links for rendering. + const replySegments = $derived(view?.reply ? linkify(view.reply.body) : []); onMount(() => void load()); @@ -121,7 +123,7 @@ {#if view?.reply}

{t('feedback.replyTitle')}

-

{view.reply.body}

+

{#each replySegments as seg, i (i)}{#if seg.href}{seg.text}{:else}{seg.text}{/if}{/each}

{/if} @@ -213,4 +215,7 @@ white-space: pre-wrap; word-break: break-word; } + .replybody a { + color: var(--accent); + }