agentbbs/internal/gopher/browser.go
Anthony Ettinger ad76e7fa16
feat(gopher): serve BBS content over Gopher + SSH-authenticated "hedgehog" (#83)
Add Gopher (RFC 1436) as a co-located protocol service, following the
internal/news pattern. Two surfaces share one read-only Resolve engine:

  - Public Gopher on :70 (RFC 1436) for any gopher client (lynx, Lagrange).
    Classic gopher is stateless with no auth verb, so this surface serves
    only public content.
  - `ssh gopher@` = "hedgehog": the same gopher wire semantics carried over
    the authenticated SSH channel (the member's key is the credential), so it
    additionally reaches members-only selectors. Gopher where gopher can,
    our own gopher-like thing over SSH where it can't.

Menus surface the member directory + homepages (public_html), an About page
(brand + MOTD), public newsgroups (allowlisted on :70, all groups on
hedgehog), and members' public files. Selectors are confined to each member's
area (path-traversal guarded). New AGENTBBS_GOPHER* env vars; docs/gopher.md
and README updated (incl. the setcap note for binding privileged :70).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 05:39:35 -07:00

250 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package gopher
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/ssh"
"github.com/profullstack/agentbbs/internal/ui"
)
// RunBrowser drives the hedgehog gopher browser over the SSH session: an
// in-process gopher client that resolves selectors with authed=true (so the
// member sees members-only content their SSH key entitles them to). This is the
// authenticated counterpart to the public RFC-1436 listener — same wire
// semantics, carried over SSH instead of port 70. member is the connecting
// member's name. Requires a PTY.
func RunBrowser(s ssh.Session, srv *Server, member string) error {
ptyReq, winCh, hasPty := s.Pty()
if !hasPty {
_, _ = s.Write([]byte("gopher needs a terminal (ssh -t gopher@<host>)\r\n"))
return nil
}
m := &browser{srv: srv, member: member, width: ptyReq.Window.Width, height: ptyReq.Window.Height}
m.load("") // start at the root menu
p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s), tea.WithAltScreen())
go func() {
for w := range winCh {
p.Send(tea.WindowSizeMsg{Width: w.Width, Height: w.Height})
}
}()
_, err := p.Run()
return err
}
var (
gTheme = ui.New(ui.Blue)
gSel = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#0b1020")).Background(ui.Blue)
)
type browser struct {
srv *Server
member string
sel string // current selector
isTxt bool // current view is a text document (vs a menu)
items []Item // menu rows (info + links) for the current menu
links []int // indices into items that are selectable
cur int // cursor position within links
lines []string
top int // first visible text line
stack []string // selector history for back navigation
status string
width int
height int
}
// load resolves sel on the authenticated surface and swaps the view to it.
func (b *browser) load(sel string) {
r := b.srv.Resolve(sel, true, b.member)
b.sel = sel
b.status = ""
switch r.Kind {
case KindMenu:
b.isTxt = false
b.items = r.Menu.Items
b.links = b.links[:0]
for i, it := range b.items {
if it.Type != TypeInfo {
b.links = append(b.links, i)
}
}
b.cur = 0
case KindText:
b.isTxt = true
b.lines = strings.Split(strings.ReplaceAll(r.Text, "\r\n", "\n"), "\n")
b.top = 0
case KindBinary:
b.status = "(binary file — not shown)"
default: // KindError
b.status = r.Text
}
}
func (b *browser) Init() tea.Cmd { return nil }
func (b *browser) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
b.width, b.height = msg.Width, msg.Height
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return b, tea.Quit
case "up", "k":
if b.isTxt {
if b.top > 0 {
b.top--
}
} else if b.cur > 0 {
b.cur--
}
case "down", "j":
if b.isTxt {
if b.top < b.maxTop() {
b.top++
}
} else if b.cur < len(b.links)-1 {
b.cur++
}
case "pgdown", " ":
if b.isTxt {
b.top = min(b.top+b.bodyRows(), b.maxTop())
}
case "pgup":
if b.isTxt {
b.top = max(b.top-b.bodyRows(), 0)
}
case "enter", "right", "l":
if !b.isTxt {
b.open()
}
case "backspace", "left", "h", "esc":
b.back()
case "g", "home":
if b.isTxt {
b.top = 0
} else {
b.cur = 0
}
}
}
return b, nil
}
// open follows the currently selected menu link.
func (b *browser) open() {
if len(b.links) == 0 {
return
}
it := b.items[b.links[b.cur]]
if strings.HasPrefix(it.Selector, "URL:") {
b.status = "external: " + strings.TrimPrefix(it.Selector, "URL:")
return
}
b.stack = append(b.stack, b.sel)
b.load(it.Selector)
}
// back returns to the previous selector.
func (b *browser) back() {
if len(b.stack) == 0 {
return
}
prev := b.stack[len(b.stack)-1]
b.stack = b.stack[:len(b.stack)-1]
b.load(prev)
}
func (b *browser) bodyRows() int {
r := b.height - 4 // header + blank + keybar + margin
if r < 1 {
return 1
}
return r
}
func (b *browser) maxTop() int {
m := len(b.lines) - b.bodyRows()
if m < 0 {
return 0
}
return m
}
func (b *browser) View() string {
var sb strings.Builder
loc := b.sel
if loc == "" {
loc = "/"
}
sb.WriteString(gTheme.Title("gopher://"+trimBBS(b.srv.host)) + ui.Dim.Render(" "+loc) + "\n\n")
rows := b.bodyRows()
if b.isTxt {
end := min(b.top+rows, len(b.lines))
for _, ln := range b.lines[b.top:end] {
sb.WriteString(truncate(ln, b.width) + "\n")
}
sb.WriteString("\n" + keyBar("↑/↓ scroll · space page · ⌫ back · q quit"))
return ui.Frame.Render(sb.String())
}
// menu view
shown := 0
for i, it := range b.items {
if shown >= rows {
break
}
shown++
if it.Type == TypeInfo {
sb.WriteString(ui.Dim.Render(truncate(it.Display, b.width)) + "\n")
continue
}
line := gopherGlyph(it.Type) + " " + it.Display
if b.links[b.cur] == i {
sb.WriteString(gSel.Render(" "+truncate(line, b.width-2)) + "\n")
} else {
sb.WriteString(" " + truncate(line, b.width-2) + "\n")
}
}
if b.status != "" {
sb.WriteString("\n" + ui.Danger.Render(b.status) + "\n")
}
sb.WriteString("\n" + keyBar("↑/↓ move · ⏎ open · ⌫ back · q quit"))
return ui.Frame.Render(sb.String())
}
// gopherGlyph is a one-rune hint of a menu item's type.
func gopherGlyph(t byte) string {
switch t {
case TypeMenu:
return "▸"
case TypeText:
return "▪"
case TypeImage:
return "▨"
case TypeHTML:
return "↗"
case TypeBinary:
return "⬦"
default:
return " "
}
}
func keyBar(s string) string { return ui.Dim.Render(s) }
func truncate(s string, w int) string {
if w <= 0 || lipgloss.Width(s) <= w {
return s
}
r := []rune(s)
if len(r) > w {
return string(r[:w])
}
return s
}