Skip to content

Commit 122a473

Browse files
committed
feat(termserver): dedicated ANSI-art message viewer
Since 1.10.5, TerminalTextSanitizer strips absolute cursor positioning from message bodies before the inline reader shows them, so genuine ANSI art reflows into unreadable text. Add a dedicated full-screen art view that renders such bodies with cursor positioning intact, while still removing OSC (title/clipboard), DCS/APC/PM, private-mode sequences and device-status/answerback queries -- the input-injection and clipboard vectors stay closed; only in-screen drawing is restored. - TerminalTextSanitizer::sanitize() gains a POLICY_POSITIONING mode and a hasPositionedAnsi() detector. - New AnsiArtViewer (telnet/src/): full-screen render + dismiss prompt; mode() reads the TERM_ANSI_ART_MODE env setting. - EchomailHandler / NetmailHandler message viewers detect art on the raw body, add an 'A' key (viewart) + Ctrl-K help item, and in 'inline' mode auto-launch the art view once per message open. - TERM_ANSI_ART_MODE: viewer (default, press A) | inline (auto-launch). i18n keys for all locales, unit tests, daemon include lists, and docs (UPGRADING_1.10.6, TerminalServer, TerminalServerDevGuide) updated.
1 parent 6a285e7 commit 122a473

16 files changed

Lines changed: 295 additions & 24 deletions

config/i18n/de/terminalserver.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,8 @@
562562
'ui.terminalserver.message.help_headers' => 'Nachrichtenkopfzeilen anzeigen',
563563
'ui.terminalserver.message.help_download' => 'Anhang herunterladen (ZMODEM)',
564564
'ui.terminalserver.message.help_images' => 'Eingebettetes Bild(er) anzeigen',
565+
'ui.terminalserver.message.help_ansi_art' => 'Als ANSI-Grafik anzeigen',
566+
'ui.terminalserver.message.ansi_art_dismiss' => 'ANSI-Grafikansicht - beliebige Taste zum Zurückkehren...',
565567
'ui.terminalserver.message.help_quit' => 'Beenden / Nachricht schliessen',
566568
'ui.terminalserver.netmail.help_delete' => 'Nachricht löschen',
567569
'ui.terminalserver.netmail.help_bookmark' => 'Nachricht mit Lesezeichen versehen / entfernen',

config/i18n/en/terminalserver.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,8 @@
563563
'ui.terminalserver.message.help_headers' => 'View message headers',
564564
'ui.terminalserver.message.help_download' => 'Download attachment (ZMODEM)',
565565
'ui.terminalserver.message.help_images' => 'View inline image(s)',
566+
'ui.terminalserver.message.help_ansi_art' => 'View as ANSI art',
567+
'ui.terminalserver.message.ansi_art_dismiss' => 'ANSI art view - press any key to return...',
566568
'ui.terminalserver.message.help_quit' => 'Quit / close message',
567569
'ui.terminalserver.netmail.help_delete' => 'Delete message',
568570
'ui.terminalserver.netmail.help_bookmark' => 'Bookmark / unsave message',

config/i18n/es/terminalserver.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,8 @@
559559
'ui.terminalserver.message.help_headers' => 'Ver encabezados del mensaje',
560560
'ui.terminalserver.message.help_download' => 'Descargar adjunto (ZMODEM)',
561561
'ui.terminalserver.message.help_images' => 'Ver imagen(es) incrustada(s)',
562+
'ui.terminalserver.message.help_ansi_art' => 'Ver como arte ANSI',
563+
'ui.terminalserver.message.ansi_art_dismiss' => 'Vista de arte ANSI - pulse cualquier tecla para volver...',
562564
'ui.terminalserver.message.help_quit' => 'Salir / cerrar mensaje',
563565
'ui.terminalserver.netmail.help_delete' => 'Eliminar mensaje',
564566
'ui.terminalserver.netmail.help_bookmark' => 'Marcar / desmarcar mensaje',

config/i18n/fr/terminalserver.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,8 @@
443443
'ui.terminalserver.message.help_headers' => 'Afficher les en-têtes du message',
444444
'ui.terminalserver.message.help_download' => 'Télécharger la pièce jointe (ZMODEM)',
445445
'ui.terminalserver.message.help_images' => 'Afficher la ou les images intégrées',
446+
'ui.terminalserver.message.help_ansi_art' => 'Afficher en art ANSI',
447+
'ui.terminalserver.message.ansi_art_dismiss' => 'Vue art ANSI - appuyez sur une touche pour revenir...',
446448
'ui.terminalserver.message.help_quit' => 'Quitter / fermer le message',
447449
'ui.terminalserver.netmail.help_delete' => 'Supprimer le message',
448450
'ui.terminalserver.netmail.help_bookmark' => 'Marquer / démarquer le message',

config/i18n/it/terminalserver.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,8 @@
563563
'ui.terminalserver.message.help_headers' => 'Visualizza intestazioni messaggio',
564564
'ui.terminalserver.message.help_download' => 'Scarica allegato (ZMODEM)',
565565
'ui.terminalserver.message.help_images' => 'Visualizza immagine/i incorporata/e',
566+
'ui.terminalserver.message.help_ansi_art' => 'Visualizza come arte ANSI',
567+
'ui.terminalserver.message.ansi_art_dismiss' => 'Vista arte ANSI - premi un tasto per tornare...',
566568
'ui.terminalserver.message.help_quit' => 'Esci / chiudi messaggio',
567569
'ui.terminalserver.netmail.help_delete' => 'Elimina messaggio',
568570
'ui.terminalserver.netmail.help_bookmark' => 'Aggiungi / rimuovi segnalibro',

config/i18n/ru/terminalserver.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,8 @@
564564
'ui.terminalserver.message.help_headers' => 'Просмотреть заголовки сообщения',
565565
'ui.terminalserver.message.help_download' => 'Скачать вложение (ZMODEM)',
566566
'ui.terminalserver.message.help_images' => 'Посмотреть встроенные изображения',
567+
'ui.terminalserver.message.help_ansi_art' => 'Показать как ANSI-графику',
568+
'ui.terminalserver.message.ansi_art_dismiss' => 'Просмотр ANSI-графики - нажмите любую клавишу для возврата...',
567569
'ui.terminalserver.message.help_quit' => 'Выход / закрыть сообщение',
568570
'ui.terminalserver.netmail.help_delete' => 'Удалить сообщение',
569571
'ui.terminalserver.netmail.help_bookmark' => 'Добавить в закладки / убрать из сохранённых',

docs/TerminalServer.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ Pipe-code rendering for plain bulletins and other ANSI/pipe text shared with the
101101
- Press `T` in the echomail viewer to download the current message as a plain-text `.txt` file via ZMODEM. The filename is derived from the message subject. Uses the built-in ZMODEM implementation by default; no additional software required.
102102
- Press `E` in the echomail viewer to forward the current message to the logged-in user's email address. Requires outbound email to be configured on the BBS; an error is shown inline if it is not.
103103
- Press `F` in the echomail viewer to forward the current message. A dialog prompts the user to choose **Echomail** (forward to another subscribed echoarea — opens compose pre-filled with a `Fwd:` subject, attribution header, and quoted body) or **Netmail** (forward as a netmail to an FTN address — uses the standard netmail compose flow). The source echoarea appears in the attribution header in both cases.
104+
- Press `A` in the echomail or netmail viewer to open the **ANSI art view** — a full-screen render of the message body with cursor positioning intact. `A` only appears (in the Ctrl-K help overlay) when the body is ANSI art. Message bodies are stripped of cursor-positioning sequences before the normal reader shows them (security hardening in 1.10.5), so art that draws itself with absolute cursor moves reflows into unreadable text there; the art view restores in-screen drawing while still blocking window-title/clipboard writes and answerback queries. The `TERM_ANSI_ART_MODE` setting selects the behaviour: `viewer` (default) is the press-`A` flow just described; `inline` makes the full-screen art view open automatically whenever an ANSI-art message is opened, with any key returning to the normal reader.
104105
- Press `G` in the echomail viewer to **add an ignore rule** for the current message's sender. A sub-menu offers three options: **By sender name** (hides all future messages from that name), **By FTN address** (hides messages from that name at that address — only shown if the message carries an FTN address), and **Subject keyword** (hides future messages from that sender whose subject contains the given keyword). Options 1 and 2 show a confirmation dialog pre-filled from the message header; option 3 prompts for a keyword string. The rule is saved via `POST /api/messages/echomail/ignore-rules`. `G` is available in the Ctrl-K help overlay only (not the status bar).
105106
- Press `G` from the **echoarea list** to open the **Ignore Rules** management screen. Rules are fetched from `GET /api/user/echomail-ignore-rules` and displayed in a paginated selectable list. Each row shows the sender name and, where set, the FTN address and subject keyword. Select a rule and press Enter or `D` to delete it after confirmation via `DELETE /api/user/echomail-ignore-rules/{id}`. `G` appears in the Ctrl-K help overlay on the echoarea list.
106107
- The **echoarea list** (the screen showing your subscribed areas) uses the same navigable list interface as message lists. Arrow Up/Down moves the highlight cursor; Arrow Left/Right (or `n`/`p`) changes pages; Enter selects the highlighted area; typing a number jumps the cursor to that row. A status bar at the bottom shows available actions. Press `/` to filter the list by tag or description, `C` to clear the filter, and (when Interests is enabled) `I` to open the interests browser. The list redraws immediately on terminal resize.

docs/TerminalServerDevGuide.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,25 @@ stay intact. A line with no escape sequences and no high bytes takes a fast
317317
byte-oriented `wordwrap()` path. Do not reintroduce a raw `wordwrap(..., true)`
318318
on text that may contain colour codes or UTF-8.
319319

320+
### ANSI art viewer
321+
322+
For bodies that are genuine ANSI art, `AnsiArtViewer` (`telnet/src/`) renders the
323+
message full-screen with cursor positioning preserved.
324+
`TerminalTextSanitizer::sanitize($raw, TerminalTextSanitizer::POLICY_POSITIONING)`
325+
keeps a whitelist of cursor-movement and erase sequences on top of SGR, while
326+
still removing OSC, DCS/APC/PM, private-mode sequences, device-status/answerback
327+
queries and C0/C1 bytes — the input-injection and clipboard/title vectors stay
328+
closed.
329+
330+
`AnsiArtViewer::isArt($rawBody)` (a wrapper over
331+
`TerminalTextSanitizer::hasPositionedAnsi()`) decides whether a message qualifies;
332+
it must be called on the **raw** body, before strict sanitization. The message
333+
viewers in `EchomailHandler` and `NetmailHandler` pass the raw body through, add
334+
an `a => 'viewart'` entry to `$extraKeys` plus a help item, and handle
335+
`case 'viewart'` by calling `AnsiArtViewer::show()`. `AnsiArtViewer::mode()`
336+
reads `TERM_ANSI_ART_MODE` (`viewer` default, or `inline` to auto-launch the view
337+
once per message open).
338+
320339
### Status Bar Discipline
321340

322341
The bottom status bar has limited width. Keep it to the **most-used primary actions only** — typically scroll, prev/next, reply, and quit. Every other key belongs exclusively in the Ctrl-K help overlay.

docs/UPGRADING_1.10.6.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ changes are made.
3232
their absolute cursor positioning. Escape sequences are now treated as
3333
zero-width and are never split; wrapping only breaks on character boundaries.
3434

35+
- **ANSI-art message viewer:** echomail and netmail whose body is ANSI art (it
36+
positions the cursor to place its pieces) can now be viewed as art. The inline
37+
reader still shows the escape-filtered, reflowed body; pressing `A` opens a
38+
dedicated full-screen view that renders the art with cursor positioning
39+
intact. That view still strips window-title/clipboard writes (OSC),
40+
answerback/device-status queries and other input-injection sequences — only
41+
in-screen drawing is restored. The `TERM_ANSI_ART_MODE` setting controls this:
42+
`viewer` (default) is the press-`A` behaviour above; `inline` opens the
43+
full-screen art view automatically whenever an art message is opened, and any
44+
key drops through to the normal reader.
45+
3546
---
3647

3748
## Upgrade Instructions

src/TerminalTextSanitizer.php

Lines changed: 64 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,38 +14,65 @@
1414
* honour them — OSC title/clipboard writes or answerback/device-status queries
1515
* that reflect input back into the session.
1616
*
17-
* The policy here is a whitelist: SGR (Select Graphic Rendition) sequences
18-
* (`ESC [ ... m`) are kept so ANSI colour survives; every other escape
19-
* sequence and every C0/C1 control byte except TAB, CR and LF is removed.
17+
* Two policies are supported:
18+
*
19+
* - {@see POLICY_STRIP} (default): a strict whitelist. Only SGR (colour/style)
20+
* sequences and TAB/CR/LF survive; every other escape sequence and C0/C1
21+
* control byte is removed. This is what the inline message reader uses.
22+
* - {@see POLICY_POSITIONING}: additionally preserves a whitelist of cursor
23+
* movement and erase sequences so genuine ANSI art renders. OSC, DCS/APC/PM,
24+
* private-mode sequences, device-status/answerback queries and C0/C1 bytes
25+
* are still removed — the input-injection and clipboard/title vectors stay
26+
* closed; only in-screen display spoofing becomes possible again. Used by the
27+
* dedicated full-screen ANSI art viewer.
2028
*/
2129
class TerminalTextSanitizer
2230
{
31+
/** Strict policy: keep SGR colour codes only. */
32+
public const POLICY_STRIP = 'strip';
33+
34+
/** Permissive policy: also keep cursor-movement and erase sequences. */
35+
public const POLICY_POSITIONING = 'positioning';
36+
37+
/**
38+
* Well-formed SGR sequence: `ESC [ <params> m`.
39+
*/
40+
private const SGR_PATTERN = '\x1b\[[0-9;:]*m';
41+
2342
/**
24-
* Strip terminal control sequences from untrusted text, keeping only SGR
25-
* colour/style codes and the TAB/CR/LF whitespace controls.
43+
* Cursor movement / erase / scroll / save-restore sequences that the
44+
* positioning policy preserves: final byte in [A-H] (CUU/CUD/CUF/CUB/CNL/
45+
* CPL/CHA/CUP), J/K (ED/EL), S/T (SU/SD), d (VPA), f (HVP), s/u (SCP/RCP).
46+
* No private ('?','<','>','=') markers and no intermediate bytes are
47+
* allowed, so mode changes and device queries never match.
48+
*/
49+
private const POSITIONING_PATTERN = '\x1b\[[0-9;]*[A-HJKSTdfsu]';
50+
51+
/**
52+
* Strip terminal control sequences from untrusted text.
2653
*
2754
* The input is expected to be UTF-8 (the canonical storage form for message
2855
* text); charset conversion to CP437/ASCII happens downstream and does not
2956
* reintroduce an ESC introducer.
3057
*
31-
* @param string $text Raw untrusted text.
32-
* @return string Text safe to word-wrap and write to a terminal.
58+
* @param string $text Raw untrusted text.
59+
* @param string $policy One of {@see POLICY_STRIP} or {@see POLICY_POSITIONING}.
60+
* @return string Text safe to write to a terminal.
3361
*/
34-
public static function sanitize(string $text): string
62+
public static function sanitize(string $text, string $policy = self::POLICY_STRIP): string
3563
{
3664
if ($text === '') {
3765
return $text;
3866
}
3967

40-
// Split on well-formed SGR sequences, keeping them as captured
41-
// delimiters. Odd-indexed parts are the SGR sequences to preserve;
42-
// even-indexed parts are ordinary text that gets fully scrubbed.
43-
$parts = preg_split(
44-
'/(\x1b\[[0-9;:]*m)/',
45-
$text,
46-
-1,
47-
PREG_SPLIT_DELIM_CAPTURE
48-
);
68+
$keep = $policy === self::POLICY_POSITIONING
69+
? '/(' . self::SGR_PATTERN . '|' . self::POSITIONING_PATTERN . ')/'
70+
: '/(' . self::SGR_PATTERN . ')/';
71+
72+
// Split on the sequences to keep, retaining them as captured delimiters.
73+
// Odd-indexed parts are the preserved sequences; even-indexed parts are
74+
// ordinary text that gets fully scrubbed.
75+
$parts = preg_split($keep, $text, -1, PREG_SPLIT_DELIM_CAPTURE);
4976

5077
if ($parts === false) {
5178
return self::scrub($text);
@@ -59,9 +86,27 @@ public static function sanitize(string $text): string
5986
return $out;
6087
}
6188

89+
/**
90+
* Whether $text contains an ANSI sequence that positions the cursor or
91+
* erases part of the screen (i.e. a CSI sequence whose final byte is a
92+
* letter other than `m`). Used to decide whether a message body is ANSI
93+
* art that warrants the dedicated art viewer.
94+
*
95+
* Operates on the raw (pre-sanitize) text.
96+
*/
97+
public static function hasPositionedAnsi(string $text): bool
98+
{
99+
if ($text === '') {
100+
return false;
101+
}
102+
103+
// CSI whose final byte is a letter other than 'm' (SGR).
104+
return (bool)preg_match('/\x1b\[[0-9;<>=?]*[A-Za-ln-z]/', $text);
105+
}
106+
62107
/**
63108
* Remove every escape sequence and disallowed control byte from a fragment
64-
* that is known to contain no SGR sequences worth keeping.
109+
* that is known to contain no sequences worth keeping.
65110
*/
66111
private static function scrub(string $text): string
67112
{
@@ -76,7 +121,7 @@ private static function scrub(string $text): string
76121
// DCS / SOS / PM / APC strings: ESC (P|X|^|_) ... ST.
77122
$text = preg_replace('/\x1b[PX^_][^\x1b]*(?:\x1b\\\\)?/', '', $text);
78123

79-
// Any CSI sequence (all non-SGR by construction, plus malformed or
124+
// Any CSI sequence (all non-preserved by construction, plus malformed or
80125
// unterminated ones): cursor movement, erase, scroll region, mode
81126
// changes, device-status queries.
82127
$text = preg_replace('/\x1b\[[0-9;:?<>=]*[ -\/]*[@-~]?/', '', $text);

0 commit comments

Comments
 (0)