// Feedback client-side limits and the attachment pre-upload gate. The gate is by // file extension only (the UI does not list the allowed types and never uploads a // rejected file); the server re-checks the same limits as the trust boundary. /** MAX_BODY caps the feedback message length, in characters (matches the backend). */ export const MAX_BODY = 1024; /** MAX_ATTACHMENT_BYTES caps a single attachment's size (matches the backend). */ export const MAX_ATTACHMENT_BYTES = 1_000_000; // Allowed attachment extensions, mirrored from the backend allow-list. const ALLOWED_EXT = new Set([ 'png', 'jpg', 'jpeg', 'webp', 'gif', // images 'pdf', 'txt', 'log', 'doc', 'docx', 'rtf', 'zip', 'gz', '7z', ]); /** * attachmentError validates a picked file by name and size, returning 'type' for a * disallowed (or missing) extension, 'size' for a too-large file, or '' when it is * acceptable. The caller shows a single generic "cannot attach" message regardless * of which non-empty reason it is, so the allowed types are never revealed. */ export function attachmentError(name: string, size: number): '' | 'type' | 'size' { const dot = name.lastIndexOf('.'); const ext = dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''; if (!ext || !ALLOWED_EXT.has(ext)) return 'type'; 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; }