Skip to content

Commit 6602a47

Browse files
committed
feat(termserver): virtual-canvas ANSI-art rendering (new default)
Add AnsiCanvasRenderer: resolves an ANSI-art byte stream against an off-screen character grid (cursor moves, erase, SGR, save/restore) and serialises the used rows back to SGR-only lines with no positioning. Ported from the browser AnsiTerminal in public_html/js/ansisys.js. TERM_ANSI_ART_MODE gains 'canvas' and it is now the default: art bodies are canvas-rendered and shown inline in the normal reader, which scrolls, repaints and resizes them like any other message -- no cursor-control code reaches the terminal, only colour. 'viewer' (old default), 'inline' and 'raw' remain. The full-screen positioned view is still on the 'A' key in every mode. - AnsiArtViewer::readerRenderMode() replaces readerSkipsWrap(); returns strict | canvas | raw and drives both the sanitize policy and the $buildView line-production branch in the Echomail/Netmail viewers. - Height capped at 1000 rows; lines clipped to the reader width and re-rendered on resize. Unit tests for the renderer and the mode helpers, daemon include lists, .env.example and the three terminal-server docs updated.
1 parent 8d0ba6b commit 6602a47

12 files changed

Lines changed: 703 additions & 101 deletions

.env.example

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -313,17 +313,21 @@ SIXEL_PIXELS_PER_COL=9
313313
SIXEL_PIXELS_PER_ROW=16
314314

315315
# ANSI-art message handling on the Telnet/SSH reader.
316-
# Message bodies are stripped of cursor-positioning sequences before the inline
317-
# reader shows them (GHSA-4225-c933-76f3), so absolute-positioned ANSI art does
318-
# not render inline.
319-
# viewer = inline reader shows the reflowed body; press A for a full-screen
320-
# art view (default)
321-
# inline = the full-screen art view opens automatically for art messages;
322-
# any key returns to the normal reader
316+
# Message bodies are stripped of cursor-positioning sequences before the plain
317+
# reader would see them (GHSA-4225-c933-76f3), so absolute-positioned ANSI art
318+
# cannot be shown verbatim.
319+
# canvas = art is rendered onto an off-screen character grid and the coloured
320+
# result is shown inline in the normal reader; press A for a
321+
# full-screen positioned render (default)
322+
# viewer = inline reader shows the reflowed body; press A for the full-screen
323+
# render
324+
# inline = the full-screen render opens automatically for art messages; any
325+
# key returns to the normal reader
323326
# raw = cursor-positioning/erase codes pass through to the normal reader
324327
# for art messages (not word-wrapped); sysop accepts in-screen
325-
# display spoofing. OSC/DCS/answerback vectors stay blocked in all modes.
326-
# TERM_ANSI_ART_MODE=viewer
328+
# display spoofing
329+
# OSC/DCS/answerback vectors stay blocked in every mode.
330+
# TERM_ANSI_ART_MODE=canvas
327331

328332
# Outbound File Requests (FREQ) feature - lets users request a file from a remote FTN node
329333
# via the web UI, terminal server UI, and /api/freq/requests.

docs/TerminalServer.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,13 @@ 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; `raw` lets the cursor-positioning and erase codes pass straight through to the normal reader for art messages (which are then not word-wrapped), so the art draws itself in place as you scroll. `raw` is the sysop opting in to in-screen display spoofing within the reader; the window-title/clipboard and answerback protections still apply in all three modes.
104+
- **ANSI-art messages** (echomail or netmail whose body positions the cursor to place its pieces) are rendered onto a virtual character canvas and shown inline in the normal reader — the reader scrolls, repaints and resizes the art like any other message, and no cursor-control code reaches your terminal. Message bodies are stripped of cursor-positioning sequences before the plain reader would otherwise see them (security hardening in 1.10.5), which is why the canvas exists: it resolves the cursor moves against an off-screen grid first. Press `A` (shown in the Ctrl-K help overlay only when the body is art) for a full-screen render with real cursor positioning for maximum fidelity.
105+
- The `TERM_ANSI_ART_MODE` setting selects how art is handled:
106+
- `canvas` (default) — virtual-canvas render inline; `A` for the full-screen view.
107+
- `viewer` — inline reader shows the escape-filtered, reflowed body; `A` for the full-screen view.
108+
- `inline` — the full-screen view opens automatically whenever an art message is opened; any key returns to the reader.
109+
- `raw` — cursor-positioning and erase codes pass straight through to the normal reader for art messages (which are then not word-wrapped), so the art draws itself in place as you scroll. This is the sysop opting in to in-screen display spoofing within the reader.
110+
The window-title/clipboard (OSC) and answerback/device-status protections apply in every mode.
105111
- 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).
106112
- 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.
107113
- 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: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -317,34 +317,41 @@ 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 sanitization. The message viewers
333-
in `EchomailHandler` and `NetmailHandler` pass the raw body through, add an
334-
`a => 'viewart'` entry to `$extraKeys` plus a help item, and handle
335-
`case 'viewart'` by calling `AnsiArtViewer::show()`.
336-
337-
`AnsiArtViewer::mode()` reads `TERM_ANSI_ART_MODE`:
338-
339-
| Mode | Reader behaviour |
340-
|------|------------------|
341-
| `viewer` (default) | Body sanitized `POLICY_STRIP` and reflowed; press `A` for `AnsiArtViewer::show()`. |
342-
| `inline` | Same as `viewer`, plus `AnsiArtViewer::show()` auto-launches once per message open. |
343-
| `raw` | For art bodies: `AnsiArtViewer::readerBodyPolicy()` returns `POLICY_POSITIONING` so the handler sanitizes permissively, and `AnsiArtViewer::readerSkipsWrap()` returns true so `$buildView` splits the body on newlines instead of calling `wrapTextLines()`. The cursor codes reach the terminal from inside the normal scroll viewer. |
344-
345-
The two `reader*` helpers take the `$isArt` flag and both no-op unless the mode
346-
is `raw` and the body is art, so every non-art message and every other mode keeps
347-
the strict path.
320+
### ANSI art
321+
322+
Two pieces cooperate. `TerminalTextSanitizer::sanitize($raw, POLICY_POSITIONING)`
323+
keeps a whitelist of cursor-movement and erase sequences on top of SGR while
324+
still removing OSC, DCS/APC/PM, private-mode sequences,
325+
device-status/answerback queries and C0/C1 bytes — the input-injection and
326+
clipboard/title vectors stay closed regardless of mode.
327+
328+
`AnsiCanvasRenderer::render($positioningSanitizedBody, $width)` (`telnet/src/`,
329+
ported from the browser `AnsiTerminal` in `public_html/js/ansisys.js`) resolves
330+
every cursor move against an off-screen cell grid (`$width` columns, height
331+
capped at 1000 rows) and serialises the used rows back to strings that carry
332+
**SGR codes only** — no positioning. The message viewers feed these straight in
333+
as `$wrappedLines`, so the scroll viewer, resize rebuild and repaint all work
334+
unchanged and nothing but colour reaches the terminal.
335+
336+
`AnsiArtViewer::show()` is the alternative: a full-screen render of the
337+
positioning-sanitized body with real cursor moves, reached with the `A` key
338+
(`a => 'viewart'` in `$extraKeys` plus a help item; `case 'viewart'` in the
339+
switch). Maximum fidelity, at the cost of clearing the screen.
340+
341+
`AnsiArtViewer::isArt($rawBody)` (over
342+
`TerminalTextSanitizer::hasPositionedAnsi()`) must be called on the **raw** body,
343+
before sanitization, to decide whether a message qualifies. `readerRenderMode()`
344+
then returns how `$buildView` should turn the body into lines:
345+
346+
| `TERM_ANSI_ART_MODE` | `readerRenderMode($isArt=true)` | Body policy | `$buildView` lines |
347+
|----------------------|--------------------------------|-------------|--------------------|
348+
| `canvas` (default) | `canvas` | `POLICY_POSITIONING` | `AnsiCanvasRenderer::render()` |
349+
| `viewer` | `strict` | `POLICY_STRIP` | `wrapTextLines()` |
350+
| `inline` | `strict` | `POLICY_STRIP` | `wrapTextLines()` (plus `show()` auto-launches once per open) |
351+
| `raw` | `raw` | `POLICY_POSITIONING` | split on newlines, no wrap |
352+
353+
For non-art bodies `readerRenderMode()` is always `strict`, so every ordinary
354+
message keeps the plain sanitize + wrap path.
348355

349356
### Status Bar Discipline
350357

docs/UPGRADING_1.10.6.md

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,29 @@ 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; `raw` passes cursor-positioning and
45-
erase sequences straight through to the normal reader for art messages (and
46-
does not word-wrap them), so the art renders in place during normal scrolling.
47-
`raw` reintroduces in-screen display spoofing inside the message reader — the
48-
sysop opts into that tradeoff; the OSC/DCS/answerback vectors stay closed in
49-
every mode.
35+
- **ANSI-art messages render on a virtual canvas:** echomail and netmail whose
36+
body is ANSI art (it positions the cursor to place its pieces) are now drawn
37+
onto an off-screen character grid and the resulting coloured lines are shown
38+
inline in the normal reader. The 1.10.5 security fix strips absolute cursor
39+
positioning from message bodies, which left ANSI art reflowing into unreadable
40+
text; the canvas resolves every cursor move against the grid first, so the art
41+
keeps its layout and the reader still scrolls, repaints and resizes it like
42+
any other message. No cursor-control code reaches the terminal — only colour.
43+
Pressing `A` in the reader opens a full-screen view that renders the art with
44+
real cursor positioning for maximum fidelity; that view (like the inline
45+
canvas) still strips window-title/clipboard writes (OSC),
46+
answerback/device-status queries and other input-injection sequences.
47+
48+
The `TERM_ANSI_ART_MODE` setting selects the behaviour:
49+
50+
| Value | Behaviour |
51+
|-------|-----------|
52+
| `canvas` (default) | Art rendered on the virtual canvas inline; `A` for the full-screen view. |
53+
| `viewer` | Inline reader shows the escape-filtered, reflowed body; `A` for the full-screen view. |
54+
| `inline` | The full-screen view opens automatically when an art message is opened; any key returns to the reader. |
55+
| `raw` | Cursor-positioning and erase codes pass straight through to the normal reader for art messages (not word-wrapped). Reintroduces in-screen display spoofing within the reader — the sysop opts in. |
56+
57+
The OSC/DCS/answerback protections apply in every mode.
5058

5159
---
5260

ssh/ssh_daemon.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require_once __DIR__ . '/../telnet/src/BbsSession.php';
99
require_once __DIR__ . '/../telnet/src/TelnetUtils.php';
1010
require_once __DIR__ . '/../telnet/src/TerminalMarkupRenderer.php';
11+
require_once __DIR__ . '/../telnet/src/AnsiCanvasRenderer.php';
1112
require_once __DIR__ . '/../telnet/src/AnsiArtViewer.php';
1213
require_once __DIR__ . '/../telnet/src/SixelImageRenderer.php';
1314
require_once __DIR__ . '/../telnet/src/TerminalBoxRenderer.php';

telnet/src/AnsiArtViewer.php

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,18 @@
2727
*/
2828
class AnsiArtViewer
2929
{
30+
/**
31+
* Art messages are rendered onto a virtual canvas (see
32+
* {@see AnsiCanvasRenderer}) and the resulting SGR-only lines are shown
33+
* inline in the normal reader, which scrolls and resizes them like any
34+
* other message. Pressing `A` still opens the full-screen positioned view.
35+
* Default.
36+
*/
37+
public const MODE_CANVAS = 'canvas';
38+
3039
/**
3140
* Inline reader shows the reflowed, escape-filtered body; the user presses
32-
* `A` for the dedicated full-screen art view. Default.
41+
* `A` for the dedicated full-screen art view.
3342
*/
3443
public const MODE_VIEWER = 'viewer';
3544

@@ -51,49 +60,61 @@ class AnsiArtViewer
5160

5261
/**
5362
* Configured art-handling mode from `TERM_ANSI_ART_MODE`. Defaults to
54-
* {@see MODE_VIEWER}; any unrecognised value also falls back to it.
63+
* {@see MODE_CANVAS}; any unrecognised value also falls back to it.
5564
*/
5665
public static function mode(): string
5766
{
58-
$mode = strtolower(trim((string)Config::env('TERM_ANSI_ART_MODE', self::MODE_VIEWER)));
67+
$mode = strtolower(trim((string)Config::env('TERM_ANSI_ART_MODE', self::MODE_CANVAS)));
5968

6069
return match ($mode) {
70+
self::MODE_VIEWER => self::MODE_VIEWER,
6171
self::MODE_INLINE => self::MODE_INLINE,
6272
self::MODE_RAW => self::MODE_RAW,
63-
default => self::MODE_VIEWER,
73+
default => self::MODE_CANVAS,
6474
};
6575
}
6676

6777
/**
6878
* Whether a raw (pre-sanitize) message body looks like positioned ANSI art
69-
* that would not survive the inline reader intact.
79+
* that would not survive the plain word-wrapping reader intact.
7080
*/
7181
public static function isArt(string $rawBody): bool
7282
{
7383
return TerminalTextSanitizer::hasPositionedAnsi($rawBody);
7484
}
7585

7686
/**
77-
* The sanitize policy the normal reader should apply to a message body.
87+
* How the normal reader should turn a message body into display lines:
7888
*
79-
* {@see TerminalTextSanitizer::POLICY_POSITIONING} only when the body is
80-
* art and the configured mode is {@see MODE_RAW}; otherwise the strict
81-
* {@see TerminalTextSanitizer::POLICY_STRIP}.
89+
* - `strict` — sanitize `POLICY_STRIP`, word-wrap (all non-art bodies, and
90+
* art bodies in `viewer` / `inline` mode).
91+
* - `canvas` — sanitize `POLICY_POSITIONING`, render through
92+
* {@see AnsiCanvasRenderer} (art body, `canvas` mode).
93+
* - `raw` — sanitize `POLICY_POSITIONING`, split on newlines only (art
94+
* body, `raw` mode).
8295
*/
83-
public static function readerBodyPolicy(bool $isArt): string
96+
public static function readerRenderMode(bool $isArt): string
8497
{
85-
return ($isArt && self::mode() === self::MODE_RAW)
86-
? TerminalTextSanitizer::POLICY_POSITIONING
87-
: TerminalTextSanitizer::POLICY_STRIP;
98+
if (!$isArt) {
99+
return 'strict';
100+
}
101+
102+
return match (self::mode()) {
103+
self::MODE_CANVAS => 'canvas',
104+
self::MODE_RAW => 'raw',
105+
default => 'strict',
106+
};
88107
}
89108

90109
/**
91-
* Whether the normal reader should render this body without word-wrapping
92-
* (raw mode, art body) so the art keeps its own line/column layout.
110+
* The sanitize policy the normal reader should apply to a message body,
111+
* derived from {@see readerRenderMode()}.
93112
*/
94-
public static function readerSkipsWrap(bool $isArt): bool
113+
public static function readerBodyPolicy(bool $isArt): string
95114
{
96-
return $isArt && self::mode() === self::MODE_RAW;
115+
return self::readerRenderMode($isArt) === 'strict'
116+
? TerminalTextSanitizer::POLICY_STRIP
117+
: TerminalTextSanitizer::POLICY_POSITIONING;
97118
}
98119

99120
/**

0 commit comments

Comments
 (0)