From bea1d3b58e7adcb8d85d2794d67e9fe0da4af3fa Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sat, 8 Aug 2026 14:15:10 -0700 Subject: [PATCH] Stop chat text from being read as color markup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC's write() renders an inline \color\text\reset\ markup, and the chat highlighter emits it deliberately. But raw message text was spliced into the same string with nothing escaping backslashes, so anyone who typed \red\hi\reset\ into chat dyed their message red in every client — user input interpreted as rendering instructions. lib/color-codes.mjs is now the one scanner both sides share. Producers escape text they did not author (\\ is a literal backslash) and move their element offsets onto the escaped copy; the renderer walks codes instead of running a global regex, so an unterminated \ is a character rather than a swallowed line. Escaping rather than stripping, so a backslash somebody legitimately typed still arrives instead of being silently eaten. Covers every consumer: chat.mjs splices per wrapped line, and applyColorCodes covers hotlink.mjs, moods, and the prompt MOTD (a user's mood, so the same hole). The shadow pass walks codes too — a regex read the two halves of an escaped backslash as a delimiter. Width measurement now shares the renderer's scanner, so a measured width still matches a drawn width. --- .../public/aesthetic.computer/disks/chat.mjs | 60 +++++++------ .../lib/chat-highlighting.mjs | 19 ++-- .../aesthetic.computer/lib/color-codes.mjs | 88 +++++++++++++++++++ system/public/aesthetic.computer/lib/disk.mjs | 48 +++++----- 4 files changed, 157 insertions(+), 58 deletions(-) create mode 100644 system/public/aesthetic.computer/lib/color-codes.mjs diff --git a/system/public/aesthetic.computer/disks/chat.mjs b/system/public/aesthetic.computer/disks/chat.mjs index f5342dd6e3..167e3b4496 100644 --- a/system/public/aesthetic.computer/disks/chat.mjs +++ b/system/public/aesthetic.computer/disks/chat.mjs @@ -38,6 +38,12 @@ import { getCommandDescription, isPromptOnlyCommand } from "../lib/prompt-comman import { parseFightCommand } from "../lib/fight/challenge.mjs"; import { FIGHT_MANIFEST } from "../lib/fight/protocol.mjs"; import { createHandleAutocomplete } from "../lib/autocomplete.mjs"; +import { + escapeColorCodes, + escapedIndexMap, + mapColorCodes, + stripColorCodes, +} from "../lib/color-codes.mjs"; import { iOS } from "../lib/platform.mjs"; // 🎨 Handle Colors System @@ -135,21 +141,13 @@ function getFontTimestampGap(fontId) { return fontConfig.timestampGap ?? 4; } +// What the text looks like once the `\color\` markup is gone — the string to +// measure, so a measured width matches a drawn width. Shares one scanner with +// the renderer; measuring with a different notion of escaping than `write` uses +// is how a line ends up wider than the box it was fitted to. function stripInlineColorCodes(s) { - // Removes inline color markup of the form: \color\text\reset\ - // Single-pass O(n) scanner. if (!s || typeof s !== "string") return ""; - let out = "", i = 0; - while (i < s.length) { - if (s[i] === "\\") { - i++; // skip opening backslash - while (i < s.length && s[i] !== "\\") i++; // skip color spec - i++; // skip closing backslash - } else { - out += s[i++]; - } - } - return out; + return stripColorCodes(s); } let input, inputBtn, handleBtn, token, currentUserSub; @@ -1198,8 +1196,11 @@ function paint( const lineStart = tempCharPos; const lineEnd = tempCharPos + line.length; - // Build color-coded version of this line - let colorCodedLine = line; + // Build color-coded version of this line. The line is chat text, so it + // gets escaped before any of our own `\…\` codes go near it; `at` moves + // element offsets onto the escaped copy. + const at = escapedIndexMap(line); + let colorCodedLine = escapeColorCodes(line); // Find elements that overlap with this line and apply colors (in reverse order) const lineElements = parsedElements @@ -1238,7 +1239,7 @@ function paint( for (let ci = 0; ci < elementText.length; ci++) { const char = elementText[ci]; const col = customColors[ci]; - perCharText += `\\${col.r},${col.g},${col.b}\\${char}\\${textColorStr}\\`; + perCharText += `\\${col.r},${col.g},${col.b}\\${escapeColorCodes(char)}\\${textColorStr}\\`; } customColorCodedText = perCharText; } else { @@ -1267,28 +1268,35 @@ function paint( color = isHovered ? theme.youtubeHover : theme.youtube; } + // Splice against the escaped line, so the offsets have to move too. + const spliceStart = at[elemStartInLine]; + const spliceEnd = at[elemEndInLine]; + if (customColorCodedText) { colorCodedLine = - colorCodedLine.substring(0, elemStartInLine) + + colorCodedLine.substring(0, spliceStart) + customColorCodedText + - colorCodedLine.substring(elemEndInLine); + colorCodedLine.substring(spliceEnd); } else if (color) { const colorStr = Array.isArray(color) ? color.join(',') : color; const textColorStr = Array.isArray(theme.messageText) ? theme.messageText.join(',') : theme.messageText; - const colorCodedText = `\\${colorStr}\\${elementText}\\${textColorStr}\\`; + const colorCodedText = `\\${colorStr}\\${escapeColorCodes(elementText)}\\${textColorStr}\\`; colorCodedLine = - colorCodedLine.substring(0, elemStartInLine) + + colorCodedLine.substring(0, spliceStart) + colorCodedText + - colorCodedLine.substring(elemEndInLine); + colorCodedLine.substring(spliceEnd); } } } - // Pre-compute shadow line - const shadowLine = colorCodedLine.replace( - /\\(\d+),(\d+),(\d+)(?:,\d+)?\\/g, - (_, r, g, b) => `\\${Math.floor(r * 0.25)},${Math.floor(g * 0.25)},${Math.floor(b * 0.25)}\\` - ); + // Pre-compute shadow line. Walked code-by-code rather than by regex: + // a regex would read the two halves of an escaped `\\` in the message + // text as a code delimiter and corrupt it. + const shadowLine = mapColorCodes(colorCodedLine, (code) => { + const rgb = code.match(/^(\d+),(\d+),(\d+)(?:,\d+)?$/); + if (!rgb) return code; + return `${floor(rgb[1] * 0.25)},${floor(rgb[2] * 0.25)},${floor(rgb[3] * 0.25)}`; + }); cachedLines.push({ colorCodedLine, shadowLine }); tempCharPos += line.length; diff --git a/system/public/aesthetic.computer/lib/chat-highlighting.mjs b/system/public/aesthetic.computer/lib/chat-highlighting.mjs index 2cc3d643ee..2985870f20 100644 --- a/system/public/aesthetic.computer/lib/chat-highlighting.mjs +++ b/system/public/aesthetic.computer/lib/chat-highlighting.mjs @@ -2,6 +2,7 @@ // Shared utilities for parsing and highlighting chat messages with @handles, URLs, 'prompts', and KidLisp code. import { isKidlispSource, tokenize, KidLisp } from "./kidlisp.mjs"; +import { escapeColorCodes, escapedIndexMap } from "./color-codes.mjs"; // Simple client-side check for potentially sensitive URLs // These URLs will show as "[click to reveal link]" in chat @@ -284,12 +285,19 @@ export function parseMessageElements(message) { // Returns a string with embedded \color\ codes for rendering // colorMap should map element types to color strings/arrays: { handle: "pink", url: "cyan", ... } export function applyColorCodes(message, elements, colorMap, defaultColor = [255, 255, 255]) { + // The `\…\` codes below are markup we own; the message is somebody else's + // typing. Escape it on the way in so a chatter who types `\red\hi\reset\` + // sees those characters instead of dyeing the room red. if (!elements || elements.length === 0) { - return message; // No highlighting needed + return escapeColorCodes(message); // No highlighting needed } + // Escaping shifts every character right of a backslash, so element offsets — + // measured against the raw message — have to move with it. + const at = escapedIndexMap(message); + // Build the color-coded string by processing elements in reverse order (to maintain indices) - let colorCodedMessage = message; + let colorCodedMessage = escapeColorCodes(message); const sortedElements = [...elements].sort((a, b) => b.start - a.start); for (const element of sortedElements) { @@ -311,9 +319,10 @@ export function applyColorCodes(message, elements, colorMap, defaultColor = [255 const colorStr = Array.isArray(color) ? color.join(',') : color; const defaultColorStr = Array.isArray(defaultColor) ? defaultColor.join(',') : defaultColor; - const textBefore = colorCodedMessage.substring(0, element.start); - const elementText = displayText || colorCodedMessage.substring(element.start, element.end); - const textAfter = colorCodedMessage.substring(element.end); + const textBefore = colorCodedMessage.substring(0, at[element.start]); + const elementText = + displayText || colorCodedMessage.substring(at[element.start], at[element.end]); + const textAfter = colorCodedMessage.substring(at[element.end]); colorCodedMessage = `${textBefore}\\${colorStr}\\${elementText}\\${defaultColorStr}\\${textAfter}`; } diff --git a/system/public/aesthetic.computer/lib/color-codes.mjs b/system/public/aesthetic.computer/lib/color-codes.mjs new file mode 100644 index 0000000000..81b0c884a0 --- /dev/null +++ b/system/public/aesthetic.computer/lib/color-codes.mjs @@ -0,0 +1,88 @@ +// 🎨 Inline color codes — the `\color\text\reset\` markup that `write` reads. + +// One scanner, shared by the renderer (`disk.mjs`) and by every producer that +// splices colors into text it did not author (chat, hotlink, moods, prompt). +// The producers own the markup; the text they wrap around is often somebody +// else's typing, so it gets escaped on the way in: `\\` is a literal +// backslash. That is the whole trick — a user who types `\red\hi\reset\` sees +// those characters instead of red text, and nothing is silently eaten. + +// Walk a string once, splitting it into rendered text and color codes. +// Returns the alternating array [text, code, text, code, …, text] — always +// odd length, always text first, the same shape `String.split` gave us before. +// A `\` with no closing `\` after it is just a backslash, not a broken code. +export function splitColorCodes(str) { + if (!str) return [str || ""]; + const parts = [""]; + let i = 0; + while (i < str.length) { + if (str[i] !== "\\") { + parts[parts.length - 1] += str[i]; + i += 1; + } else if (str[i + 1] === "\\") { + parts[parts.length - 1] += "\\"; // an escaped backslash renders as one + i += 2; + } else { + const close = str.indexOf("\\", i + 1); + if (close === -1) { + parts[parts.length - 1] += "\\"; // unterminated — just a character + i += 1; + } else { + parts.push(str.slice(i + 1, close), ""); + i = close + 1; + } + } + } + return parts; +} + +// What the text looks like once the codes are gone — the string to measure so +// a measured width matches a drawn width. +export function stripColorCodes(str) { + if (!str) return str; + const parts = splitColorCodes(str); + let out = ""; + for (let i = 0; i < parts.length; i += 2) out += parts[i]; + return out; +} + +// Is there a real `\code\` pair in here? (Used to sniff kidlisp-ish text.) +export function hasColorCodes(str) { + if (!str) return false; + return splitColorCodes(str).length > 1; +} + +// Make text safe to splice next to color codes. +export function escapeColorCodes(str) { + if (!str) return str || ""; + return str.replace(/\\/g, "\\\\"); +} + +// Escaping shifts every character right of a backslash, so a producer that +// holds offsets into the raw string needs to translate them. `map[i]` is where +// raw character `i` landed in `escapeColorCodes(str)`; `map[str.length]` is the +// end, so a raw [start, end) slice maps straight across. +export function escapedIndexMap(str) { + const map = new Array((str?.length || 0) + 1); + let shift = 0; + for (let i = 0; i < (str?.length || 0); i++) { + map[i] = i + shift; + if (str[i] === "\\") shift += 1; + } + map[str?.length || 0] = (str?.length || 0) + shift; + return map; +} + +// Rewrite every code in place (shadow passes do this), leaving the text alone. +// Return `null` from `fn` to drop a code entirely. +export function mapColorCodes(str, fn) { + if (!str) return str; + const parts = splitColorCodes(str); + let out = escapeColorCodes(parts[0]); + for (let i = 1; i < parts.length; i += 2) { + const replacement = fn(parts[i]); + if (replacement !== null && replacement !== undefined) out += `\\${replacement}\\`; + out += escapeColorCodes(parts[i + 1]); + } + return out; +} diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index 52456ccb2f..afea2c23f1 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -29,6 +29,12 @@ import * as vec3 from "../dep/gl-matrix/vec3.mjs"; import * as vec4 from "../dep/gl-matrix/vec4.mjs"; import * as graph from "./graph.mjs"; +import { + splitColorCodes, + stripColorCodes as stripCodes, + hasColorCodes, + mapColorCodes, +} from "./color-codes.mjs"; import * as num from "./num.mjs"; import * as text from "./text.mjs"; @@ -591,9 +597,9 @@ function replaceColorCodesWithShadows(text, defaultTextColor = "white") { let currentTextColor = defaultTextColor; - COLOR_CODE_MATCH_REGEX.lastIndex = 0; - return text.replace(COLOR_CODE_MATCH_REGEX, (match, colorStr) => { - if (!colorStr) return match; + // Rewrite every code, leave the text (and its escapes) alone. + return mapColorCodes(text, (colorStr) => { + if (!colorStr) return colorStr; const normalized = colorStr.trim(); const lower = normalized.toLowerCase(); @@ -604,11 +610,9 @@ function replaceColorCodesWithShadows(text, defaultTextColor = "white") { currentTextColor = normalized; } - // Get the appropriate shadow color for this text color - const shadowColor = getShadowColorForText(currentTextColor); - - // Return the shadow color code - return `\\${shadowColor}\\`; + // Get the appropriate shadow color for this text color. + // mapColorCodes re-wraps this in the `\…\` delimiters itself. + return getShadowColorForText(currentTextColor); }); } @@ -2074,26 +2078,17 @@ if (typeof window !== 'undefined') { } //currentPromptButton; -const COLOR_CODE_MATCH_REGEX = /\\([^\\]+)\\/g; -const COLOR_CODE_TEST_REGEX = /\\[^\\]+\\/; +// Color codes are scanned by lib/color-codes.mjs, which knows that `\\` is an +// escaped backslash — the producers escape text they didn't author, so a user +// who types `\red\` gets those characters instead of red text. // Utility function to strip color codes from text function stripColorCodes(str) { - if (!str) return str; - // Remove all \\color\\ sequences including: - // - Named colors: \\red\\, \\blue\\, \\cyan\\ - // - RGB values: \\255,20,147\\, \\192,192,192\\ - // - Complex patterns: \\color(args)\\ - COLOR_CODE_MATCH_REGEX.lastIndex = 0; - return str.replace( - COLOR_CODE_MATCH_REGEX, - "", - ); + return stripCodes(str); } function textContainsColorCodes(str) { - if (!str) return false; - return COLOR_CODE_TEST_REGEX.test(str); + return hasColorCodes(str); } function hasKidLispMarkers(text) { @@ -5589,9 +5584,10 @@ const $paintApi = { let charColors = []; let currentColor = null; - // Split text by color codes and process each segment - COLOR_CODE_MATCH_REGEX.lastIndex = 0; - const segments = text.split(COLOR_CODE_MATCH_REGEX); + // Split text by color codes and process each segment. + // [text, code, text, code, …] — the text halves arrive unescaped, so a + // literal backslash the author typed lands in cleanText as one character. + const segments = splitColorCodes(text); for (let i = 0; i < segments.length; i++) { if (i % 2 === 0) { @@ -5651,8 +5647,6 @@ const $paintApi = { } } - COLOR_CODE_MATCH_REGEX.lastIndex = 0; - // Check if we have any actual text to display after removing color codes if (cleanText.trim().length === 0) { return $activePaintApi; // Exit silently if no text content remains -- 2.51.2