Compare commits

...

2 Commits

Author SHA1 Message Date
Ilia Denisov 277954c47f feat(feedback): render links in the operator reply (open in a new tab)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
Linkify the operator reply on the feedback screen: http/https/ftp/mailto/tel URLs
become anchors (target=_blank rel=noopener), everything else stays escaped text.
A small whitelisted regex (no dependency) — the reply is operator-authored, so
explicit schemes suffice; dangerous schemes (javascript:/data:) are never linked,
and \b avoids matching a scheme inside a word (hotel:, email:).
2026-06-15 13:25:36 +02:00
Ilia Denisov 55ed87fb11 feat(feedback): snapshot the sender's language and connector bot at submit
Store the sender's interface language (lang) and, for a message that arrived
through an external connector (Telegram), the bot language (channel_lang) on the
feedback row at submit time, so the operator console shows the state as it was
rather than the account's current settings (same snapshot discipline as a
suspension reason). Added additively in migration 00005. The console detail reads
these columns instead of loading the account live.
2026-06-15 13:25:27 +02:00
15 changed files with 221 additions and 37 deletions
+9 -1
View File
@@ -111,7 +111,15 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string
} else { } else {
attachmentName = "" // a name without bytes carries no attachment attachmentName = "" // a name without bytes carries no attachment
} }
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, normalizeChannel(channel), parseIP(senderIP)) ch := normalizeChannel(channel)
// Snapshot the languages at submit time (acc is already loaded for the guest check):
// the sender's interface language, and the connector bot language when the message
// came through an external connector (currently Telegram).
var channelLang string
if ch == "telegram" {
channelLang = acc.ServiceLanguage
}
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, channelLang, parseIP(senderIP))
return err return err
} }
+22 -9
View File
@@ -34,8 +34,11 @@ func NewStore(db *sql.DB) *Store {
// Insert stores one feedback message from accountID and returns its id. attachment // Insert stores one feedback message from accountID and returns its id. attachment
// is the raw file bytes (nil for none); attachmentName, ip and a non-default // is the raw file bytes (nil for none); attachmentName, ip and a non-default
// channel are stored as given. created_at defaults to now() in the database. // channel are stored as given. lang (the sender's interface language) and channelLang
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel string, ip *string) (uuid.UUID, error) { // (the connector bot language, empty for a non-connector channel) are snapshots taken
// now, so the operator later sees the state at submit time. created_at defaults to
// now() in the database.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, channelLang string, ip *string) (uuid.UUID, error) {
id, err := uuid.NewV7() id, err := uuid.NewV7()
if err != nil { if err != nil {
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err) return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
@@ -44,20 +47,24 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, at
if len(attachment) > 0 { if len(attachment) > 0 {
att = attachment att = attachment
} }
var name *string
if attachmentName != "" {
name = &attachmentName
}
if _, err := s.db.ExecContext(ctx, if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.feedback_messages `INSERT INTO backend.feedback_messages
(message_id, account_id, body, attachment, attachment_name, channel, sender_ip) (message_id, account_id, body, attachment, attachment_name, channel, lang, channel_lang, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
id, accountID, body, att, name, channel, ip); err != nil { id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(channelLang), ip); err != nil {
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err) return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
} }
return id, nil return id, nil
} }
// nullStr maps an empty string to a NULL text bind, else the value.
func nullStr(s string) *string {
if s == "" {
return nil
}
return &s
}
// HasUnread reports whether the account has any message the operator has not yet // HasUnread reports whether the account has any message the operator has not yet
// dealt with (read_at IS NULL) — the anti-spam gate's condition. // dealt with (read_at IS NULL) — the anti-spam gate's condition.
func (s *Store) HasUnread(ctx context.Context, accountID uuid.UUID) (bool, error) { func (s *Store) HasUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
@@ -221,6 +228,10 @@ type AdminMessage struct {
Source string Source string
Body string Body string
Channel string Channel string
// Lang is the sender's interface language and ChannelLang the connector bot language
// (en/ru, empty for a non-connector channel) — both snapshotted at submit time.
Lang string
ChannelLang string
SenderIP string SenderIP string
HasAttachment bool HasAttachment bool
AttachmentName string AttachmentName string
@@ -335,6 +346,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
var m AdminMessage var m AdminMessage
var repliedAt sql.NullTime var repliedAt sql.NullTime
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel, q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
COALESCE(m.lang, ''), COALESCE(m.channel_lang, ''),
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''), COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL), (m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
COALESCE(m.reply_body, ''), m.replied_at, m.created_at COALESCE(m.reply_body, ''), m.replied_at, m.created_at
@@ -343,6 +355,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
WHERE m.message_id = $1` WHERE m.message_id = $1`
err := s.db.QueryRowContext(ctx, q, id).Scan( err := s.db.QueryRowContext(ctx, q, id).Scan(
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel, &m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
&m.Lang, &m.ChannelLang,
&m.SenderIP, &m.HasAttachment, &m.AttachmentName, &m.SenderIP, &m.HasAttachment, &m.AttachmentName,
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt) &m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
+45
View File
@@ -145,6 +145,51 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
} }
} }
func TestFeedbackSnapshotsLanguages(t *testing.T) {
ctx := context.Background()
svc := newFeedbackService()
acc := provisionAccount(t)
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc); err != nil {
t.Fatalf("set languages: %v", err)
}
// A Telegram (connector) message snapshots both the interface language and the bot.
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil {
t.Fatalf("submit: %v", err)
}
id := latestFeedbackID(t, svc, acc)
if m, err := svc.AdminGet(ctx, id); err != nil {
t.Fatalf("admin get: %v", err)
} else if m.Lang != "en" || m.ChannelLang != "ru" {
t.Fatalf("snapshot = lang %q / channel_lang %q, want en / ru", m.Lang, m.ChannelLang)
}
// Changing the account afterwards must not change the stored snapshot.
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'ru', service_language = 'en' WHERE account_id = $1`, acc); err != nil {
t.Fatalf("change languages: %v", err)
}
if m, err := svc.AdminGet(ctx, id); err != nil {
t.Fatal(err)
} else if m.Lang != "en" || m.ChannelLang != "ru" {
t.Fatalf("snapshot drifted after account change = lang %q / channel_lang %q", m.Lang, m.ChannelLang)
}
// A non-connector channel records no bot language even when the account has one.
acc2 := provisionAccount(t)
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc2); err != nil {
t.Fatalf("set languages 2: %v", err)
}
if err := svc.Submit(ctx, acc2, "from web", nil, "", "web", ""); err != nil {
t.Fatalf("submit web: %v", err)
}
if m, err := svc.AdminGet(ctx, latestFeedbackID(t, svc, acc2)); err != nil {
t.Fatal(err)
} else if m.Lang != "en" || m.ChannelLang != "" {
t.Fatalf("web snapshot = lang %q / channel_lang %q, want en / empty", m.Lang, m.ChannelLang)
}
}
func TestFeedbackBanRole(t *testing.T) { func TestFeedbackBanRole(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newFeedbackService() svc := newFeedbackService()
@@ -20,6 +20,8 @@ type FeedbackMessages struct {
AttachmentName *string AttachmentName *string
SenderIP *string SenderIP *string
Channel string Channel string
Lang *string
ChannelLang *string
ReadAt *time.Time ReadAt *time.Time
ArchivedAt *time.Time ArchivedAt *time.Time
ReplyBody *string ReplyBody *string
@@ -24,6 +24,8 @@ type feedbackMessagesTable struct {
AttachmentName postgres.ColumnString AttachmentName postgres.ColumnString
SenderIP postgres.ColumnString SenderIP postgres.ColumnString
Channel postgres.ColumnString Channel postgres.ColumnString
Lang postgres.ColumnString
ChannelLang postgres.ColumnString
ReadAt postgres.ColumnTimestampz ReadAt postgres.ColumnTimestampz
ArchivedAt postgres.ColumnTimestampz ArchivedAt postgres.ColumnTimestampz
ReplyBody postgres.ColumnString ReplyBody postgres.ColumnString
@@ -78,14 +80,16 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
AttachmentNameColumn = postgres.StringColumn("attachment_name") AttachmentNameColumn = postgres.StringColumn("attachment_name")
SenderIPColumn = postgres.StringColumn("sender_ip") SenderIPColumn = postgres.StringColumn("sender_ip")
ChannelColumn = postgres.StringColumn("channel") ChannelColumn = postgres.StringColumn("channel")
LangColumn = postgres.StringColumn("lang")
ChannelLangColumn = postgres.StringColumn("channel_lang")
ReadAtColumn = postgres.TimestampzColumn("read_at") ReadAtColumn = postgres.TimestampzColumn("read_at")
ArchivedAtColumn = postgres.TimestampzColumn("archived_at") ArchivedAtColumn = postgres.TimestampzColumn("archived_at")
ReplyBodyColumn = postgres.StringColumn("reply_body") ReplyBodyColumn = postgres.StringColumn("reply_body")
RepliedAtColumn = postgres.TimestampzColumn("replied_at") RepliedAtColumn = postgres.TimestampzColumn("replied_at")
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at") ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at") CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn} allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn} mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn} defaultColumns = postgres.ColumnList{CreatedAtColumn}
) )
@@ -100,6 +104,8 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
AttachmentName: AttachmentNameColumn, AttachmentName: AttachmentNameColumn,
SenderIP: SenderIPColumn, SenderIP: SenderIPColumn,
Channel: ChannelColumn, Channel: ChannelColumn,
Lang: LangColumn,
ChannelLang: ChannelLangColumn,
ReadAt: ReadAtColumn, ReadAt: ReadAtColumn,
ArchivedAt: ArchivedAtColumn, ArchivedAt: ArchivedAtColumn,
ReplyBody: ReplyBodyColumn, ReplyBody: ReplyBodyColumn,
@@ -15,7 +15,8 @@ SET search_path = backend, pg_catalog;
-- attachment is the raw bytes (capped at 1,000,000 in Go); attachment_name carries the original -- attachment is the raw bytes (capped at 1,000,000 in Go); attachment_name carries the original
-- file name (its extension drives the safe content-type at serve time). sender_ip is the -- file name (its extension drives the safe content-type at serve time). sender_ip is the
-- gateway-forwarded client IP (validated, like chat); channel is the submitting platform -- gateway-forwarded client IP (validated, like chat); channel is the submitting platform
-- (telegram/ios/android/web, validated in Go). -- (telegram/ios/android/web, validated in Go). The sender's interface/bot language snapshots
-- (lang, channel_lang) are added additively in 00005.
CREATE TABLE feedback_messages ( CREATE TABLE feedback_messages (
message_id uuid PRIMARY KEY, message_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
@@ -0,0 +1,15 @@
-- +goose Up
-- Snapshot the sender's languages on each feedback message, taken at submit time so the
-- operator console shows the state as it was, not the account's current settings (the same
-- snapshot discipline as a suspension's reason). lang is the sender's interface language;
-- channel_lang is the connector bot language (en/ru) when the message arrived through an
-- external connector (Telegram), else NULL. Additive over 00004 so it applies forward-safe.
SET search_path = backend, pg_catalog;
ALTER TABLE feedback_messages ADD COLUMN lang text;
ALTER TABLE feedback_messages ADD COLUMN channel_lang text;
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE feedback_messages DROP COLUMN IF EXISTS channel_lang;
ALTER TABLE feedback_messages DROP COLUMN IF EXISTS lang;
@@ -79,7 +79,8 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
} }
view := adminconsole.FeedbackDetailView{ view := adminconsole.FeedbackDetailView{
ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName, ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName,
Source: m.Source, Channel: m.Channel, IP: m.SenderIP, Body: m.Body, Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang, BotLanguage: m.ChannelLang,
IP: m.SenderIP, Body: m.Body,
HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName), HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody, Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt), RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt),
@@ -87,14 +88,6 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil { if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil {
view.Banned = banned view.Banned = banned
} }
if acc, err := s.accounts.GetByID(ctx, m.AccountID); err == nil {
view.InterfaceLanguage = acc.PreferredLanguage
// The connector bot (the account's last service-language bot) is meaningful only
// for a message that arrived through an external connector — currently Telegram.
if m.Channel == "telegram" && acc.ServiceLanguage != "" {
view.BotLanguage = acc.ServiceLanguage
}
}
s.renderConsole(c, "feedback_detail", "feedback", "Feedback", view) s.renderConsole(c, "feedback_detail", "feedback", "Feedback", view)
} }
+2 -2
View File
@@ -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 "Ваше сообщение 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 отправлено", and sending is blocked until the operator has dealt with that message — on re-entry
the screen reads "Ожидаем рассмотрения вашего последнего обращения". The operator's reply appears the screen reads "Ожидаем рассмотрения вашего последнего обращения". The operator's reply appears
below the form as "Ответ на ваше последнее сообщение"; it is marked read once the screen shows it below the form as "Ответ на ваше последнее сообщение" (any link in it opens in a new tab); it is
and disappears a week later. A badge on the Settings tab (and on Info inside it) flags an 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 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. barred from feedback (a role, not a full account block) sees the send control disabled.
+2 -2
View File
@@ -184,8 +184,8 @@ UTC), суточного окна отсутствия (away; сетка по 10
допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а допустимых типов). После отправки форма очищается и подтверждает «Ваше сообщение отправлено», а
повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране повторная отправка блокируется, пока оператор не разобрал обращение — при повторном входе на экране
«Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как «Ожидаем рассмотрения вашего последнего обращения». Ответ оператора показывается под формой как
«Ответ на ваше последнее сообщение»; он помечается прочитанным, как только экран его показал, и «Ответ на ваше последнее сообщение» (ссылки в нём открываются в новой вкладке); он помечается
исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном прочитанным, как только экран его показал, и исчезает через неделю. Бейдж на вкладке Settings (и на Info внутри неё) сигналит о непрочитанном
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной. обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
+5 -1
View File
@@ -43,7 +43,11 @@ test('feedback: an operator reply raises the badge and shows on the screen', asy
await openFeedback(page); await openFeedback(page);
await expect(page.getByText('Reply to your last message')).toBeVisible(); 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 }) => { test('feedback: sending a new message clears the previous reply', async ({ page }) => {
+54 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { attachmentError, MAX_ATTACHMENT_BYTES } from './feedback'; import { attachmentError, linkify, MAX_ATTACHMENT_BYTES } from './feedback';
describe('attachmentError', () => { describe('attachmentError', () => {
it('accepts allowed extensions within the size cap', () => { it('accepts allowed extensions within the size cap', () => {
@@ -22,3 +22,56 @@ describe('attachmentError', () => {
expect(attachmentError('shot.png', MAX_ATTACHMENT_BYTES + 1)).toBe('size'); 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);
});
});
+39
View File
@@ -27,3 +27,42 @@ export function attachmentError(name: string, size: number): '' | 'type' | 'size
if (size > MAX_ATTACHMENT_BYTES) return 'size'; if (size > MAX_ATTACHMENT_BYTES) return 'size';
return ''; 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;
}
+1 -1
View File
@@ -435,7 +435,7 @@ export class MockGateway implements GatewayClient {
// mockAdminReply simulates an operator reply: it clears the pending message, sets the // 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: // reply and raises the badge via a live admin_reply notification. e2e hook:
// window.__mock.adminReply. // 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.feedbackPending = false;
this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) }; this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) };
this.feedbackReplyUnread = true; this.feedbackReplyUnread = true;
+7 -2
View File
@@ -6,7 +6,7 @@
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { clientChannel } from '../lib/channel'; 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'; import type { FeedbackState } from '../lib/model';
// The feedback screen: a message (+ optional single attachment) the player sends to // The feedback screen: a message (+ optional single attachment) the player sends to
@@ -23,6 +23,8 @@
const enabled = $derived(view?.canSend ?? false); const enabled = $derived(view?.canSend ?? false);
const reason = $derived(view?.blockedReason ?? ''); const reason = $derived(view?.blockedReason ?? '');
const canSend = $derived(enabled && body.trim().length > 0 && !sending && connection.online); 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()); onMount(() => void load());
@@ -121,7 +123,7 @@
{#if view?.reply} {#if view?.reply}
<section class="reply"> <section class="reply">
<h2>{t('feedback.replyTitle')}</h2> <h2>{t('feedback.replyTitle')}</h2>
<p class="replybody">{view.reply.body}</p> <p class="replybody">{#each replySegments as seg, i (i)}{#if seg.href}<a href={seg.href} target="_blank" rel="noopener noreferrer">{seg.text}</a>{:else}{seg.text}{/if}{/each}</p>
</section> </section>
{/if} {/if}
</div> </div>
@@ -213,4 +215,7 @@
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
.replybody a {
color: var(--accent);
}
</style> </style>