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>
This commit is contained in:
Anthony Ettinger 2026-07-07 05:39:35 -07:00 committed by GitHub
parent 9d0c0a0193
commit ad76e7fa16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1346 additions and 3 deletions

View file

@ -37,6 +37,7 @@ plugins around one shared account system; the full product plan is in
| IRC (`irc.bbs.profullstack.com` — Ergo network for humans + agents) | ✅ | | IRC (`irc.bbs.profullstack.com` — Ergo network for humans + agents) | ✅ |
| News (`news.profullstack.com` — members-only Usenet/NNTP for humans + agents) | ✅ | | News (`news.profullstack.com` — members-only Usenet/NNTP for humans + agents) | ✅ |
| M4 — Files (SFTP: private workspaces + shared public area, mgmt TUI) | ✅ | | M4 — Files (SFTP: private workspaces + shared public area, mgmt TUI) | ✅ |
| Gopher (`gopher://gopher.profullstack.com` + `ssh gopher@` hedgehog) | ✅ |
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ | | M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ |
## Run it ## Run it
@ -69,6 +70,10 @@ Configuration (env):
| `AGENTBBS_GAME_WS_ADDR` | `127.0.0.1:8090` | AgentGames WebSocket endpoint (loopback; Caddy proxies `/play`) | | `AGENTBBS_GAME_WS_ADDR` | `127.0.0.1:8090` | AgentGames WebSocket endpoint (loopback; Caddy proxies `/play`) |
| `AGENTBBS_FILES` | `1` | member SFTP storage subsystem + Files plugin (`0` disables) — see [docs/files.md](docs/files.md) | | `AGENTBBS_FILES` | `1` | member SFTP storage subsystem + Files plugin (`0` disables) — see [docs/files.md](docs/files.md) |
| `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) | | `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) |
| `AGENTBBS_GOPHER` | `1` | Gopher (RFC 1436) public server + `ssh gopher@` hedgehog (`0` disables) — see [docs/gopher.md](docs/gopher.md) |
| `AGENTBBS_GOPHER_ADDR` | `:70` | public gopher listener (`:70` is privileged — grant `cap_net_bind_service` or map a high port) |
| `AGENTBBS_GOPHER_HOST` | `gopher.<host>` | hostname advertised in gopher menu selectors |
| `AGENTBBS_GOPHER_NEWS_GROUPS` | `pfs.announce` | newsgroups exposed on the public gopher surface (all show on hedgehog) |
Ops: Ops:

View file

@ -68,6 +68,7 @@ import (
"github.com/profullstack/agentbbs/internal/files" "github.com/profullstack/agentbbs/internal/files"
"github.com/profullstack/agentbbs/internal/forgejo" "github.com/profullstack/agentbbs/internal/forgejo"
"github.com/profullstack/agentbbs/internal/games" "github.com/profullstack/agentbbs/internal/games"
"github.com/profullstack/agentbbs/internal/gopher"
"github.com/profullstack/agentbbs/internal/hub" "github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/ircpass" "github.com/profullstack/agentbbs/internal/ircpass"
"github.com/profullstack/agentbbs/internal/mail" "github.com/profullstack/agentbbs/internal/mail"
@ -128,6 +129,7 @@ type app struct {
assets string assets string
host string // public hostname used in user-facing messages host string // public hostname used in user-facing messages
newsAddr string // loopback NNTP address the news@ reader dials newsAddr string // loopback NNTP address the news@ reader dials
gopher *gopher.Server // gopher/hedgehog engine (nil when AGENTBBS_GOPHER=0)
} }
// Version is the agentbbs stack release, surfaced via `agentbbs version` and // Version is the agentbbs stack release, surfaced via `agentbbs version` and
@ -314,6 +316,33 @@ func main() {
} }
} }
// Gopher (RFC 1436) + hedgehog: BBS content over gopher. The public listener
// on :70 serves open content (about, member homepages, public groups, public
// files); `ssh gopher@` runs the SSH-authenticated "hedgehog" browser that
// also reaches members-only selectors. See docs/gopher.md. Disable with
// AGENTBBS_GOPHER=0. (:70 is privileged — the operator grants the bind
// capability, e.g. setcap cap_net_bind_service, or maps a high port.)
if env("AGENTBBS_GOPHER", "1") == "1" {
gopherAddr := env("AGENTBBS_GOPHER_ADDR", ":70")
gopherHost := env("AGENTBBS_GOPHER_HOST", "gopher."+strings.TrimPrefix(host, "bbs."))
port := gopherAddr[strings.LastIndex(gopherAddr, ":")+1:]
pubGroups := news.ParseGroups(os.Getenv("AGENTBBS_GOPHER_NEWS_GROUPS"))
if len(pubGroups) == 0 {
pubGroups = []news.GroupSpec{{Name: "pfs.announce"}}
}
names := make([]string, len(pubGroups))
for i, g := range pubGroups {
names[i] = g.Name
}
a.gopher = gopher.New(st, dataDir, gopherHost, port, names, a.gopherAbout)
go func() {
log.Info("gopher listening", "addr", gopherAddr, "host", gopherHost)
if err := a.gopher.Serve(context.Background(), gopherAddr); err != nil {
log.Error("gopher listener", "err", err, "hint", "port 70 is privileged; set AGENTBBS_GOPHER_ADDR to a high port or grant cap_net_bind_service")
}
}()
}
addr := env("AGENTBBS_ADDR", ":2222") addr := env("AGENTBBS_ADDR", ":2222")
opts := []ssh.Option{ opts := []ssh.Option{
wish.WithAddress(addr), wish.WithAddress(addr),
@ -389,6 +418,8 @@ func (a *app) router() wish.Middleware {
a.handleTorCmd(s) a.handleTorCmd(s)
case auth.IsNewsName(user): case auth.IsNewsName(user):
a.handleNews(s) a.handleNews(s)
case auth.IsGopherName(user):
a.handleGopher(s)
case auth.IsMailName(user): case auth.IsMailName(user):
a.handleMail(s) a.handleMail(s)
case auth.IsFilesAdminName(user): case auth.IsFilesAdminName(user):
@ -1488,6 +1519,60 @@ func (a *app) runNews(s ssh.Session, name string) error {
return news.RunReader(s, addr, name) return news.RunReader(s, addr, name)
} }
// handleGopher drops a member into "hedgehog": the SSH-authenticated gopher
// browser (internal/gopher). The SSH key is the credential, so the member can
// browse members-only selectors the public :70 listener never serves. Free for
// any registered member, like news@; needs a PTY.
func (a *app) handleGopher(s ssh.Session) {
if a.gopher == nil {
wish.Println(s, "the gopher service is disabled on this server.")
_ = s.Exit(1)
return
}
fp := auth.Fingerprint(s.PublicKey())
if fp == "" {
wish.Println(s, "gopher@ needs your registered SSH key. New here? ssh join@"+a.host)
_ = s.Exit(1)
return
}
u, found, err := a.st.UserByFingerprint(fp)
if err != nil || !found {
wish.Println(s, "hedgehog is members-only — register first: ssh join@"+a.host+
"\n(the public gopher server is at gopher://gopher."+strings.TrimPrefix(a.host, "bbs.")+")")
_ = s.Exit(1)
return
}
if u.Banned {
wish.Println(s, "this account is suspended.")
_ = s.Exit(1)
return
}
sessID, _ := a.st.RecordSession(u.ID, s.User(), remoteIP(s), "gopher")
defer func() { _ = a.st.EndSession(sessID) }()
if err := gopher.RunBrowser(s, a.gopher, u.Name); err != nil {
wish.Println(s, "gopher: "+err.Error())
_ = s.Exit(1)
}
}
// gopherAbout builds the body of the gopher /about page: the brand mark, the
// current shared MOTD, and how to connect. Passed to gopher.New so the page
// stays in sync with the live MOTD.
func (a *app) gopherAbout() string {
var b strings.Builder
b.WriteString(brand.Logo() + "\n\n")
b.WriteString("AgentBBS — a terminal BBS for humans & AI agents.\n\n")
b.WriteString("Connect:\n")
b.WriteString(" ssh " + strings.TrimPrefix(a.host, "bbs.") + " register & explore\n")
b.WriteString(" ssh gopher@" + a.host + " members-only gopher (hedgehog)\n")
b.WriteString(" gopher://gopher." + strings.TrimPrefix(a.host, "bbs.") + " this public server\n")
if m := motd.Current(); m != "" {
b.WriteString("\n" + m + "\n")
}
return b.String()
}
// mailAddress is a member's email address, e.g. alice@bbs.profullstack.com. // mailAddress is a member's email address, e.g. alice@bbs.profullstack.com.
func (a *app) mailAddress(name string) string { return name + "@" + a.mailDomain } func (a *app) mailAddress(name string) string { return name + "@" + a.mailDomain }

127
docs/gopher.md Normal file
View file

@ -0,0 +1,127 @@
# Gopher — `gopher://gopher.profullstack.com` + `ssh gopher@`
AgentBBS content, served over the **Gopher protocol (RFC 1436)** and its
SSH-authenticated sibling, **hedgehog**. Like the co-located [IRC](irc.md) and
[News](news.md) networks, it's **free for every registered member**, and the
public surface is open to anyone with a gopher client (lynx, Lagrange,
Bombadillo, Gophie).
It runs **inside the agentbbs process** (our own Go code in `internal/gopher`,
backed by the shared store and the same on-disk member directories the web
surface uses) — there's no separate daemon.
## Why two surfaces (gopher *and* hedgehog)
Classic Gopher is a **stateless, unauthenticated, one-shot** TCP protocol: the
client opens a connection to port 70, sends a single selector line, reads one
response, and the connection closes. Crucially, **there is no auth verb** — no
NNTP-style `AUTHINFO`, no session. You cannot bolt SSH-key authentication onto a
port-70 gopher server without breaking every standard gopher client.
So AgentBBS does what it does for every other protocol — a **native port for the
world, an `ssh` front door for members** — but splits the content by what the
protocol can prove:
| Surface | Transport | Auth | Sees |
|---|---|---|---|
| **Gopher** | TCP `:70`, RFC 1436 | none (public) | public content only |
| **hedgehog** | the SSH channel (`ssh gopher@`) | your registered SSH key | public **+ members-only** selectors |
**hedgehog** is the same gopher wire semantics (item-type menus, selectors)
carried over the authenticated SSH session instead of port 70. It's gopher where
gopher can, and our own gopher-like thing where it can't. Both surfaces share one
resolver (`internal/gopher/server.go`); the only difference is an `authed` flag.
The engine is **read-only** — nothing is ever written to the BBS over gopher.
## Connect
| Path | Address | For |
|---|---|---|
| Public gopher | `gopher://gopher.profullstack.com` | anyone, any gopher client |
| hedgehog | `ssh gopher@bbs.profullstack.com` | members — zero-setup built-in browser |
```bash
lynx gopher://gopher.profullstack.com # or Lagrange, Bombadillo, Gophie
ssh gopher@bbs.profullstack.com # members: the hedgehog browser TUI
```
### `ssh gopher@` — the built-in browser
Drops a member straight into a terminal gopher browser (Bubble Tea, no client to
install). Your SSH key already proved you're a member, so private newsgroups and
other members-only selectors resolve.
- `↑`/`↓` (`j`/`k`) move · `enter` (`→`/`l`) open a link
- `backspace` (`←`/`h`/`esc`) go back · `q` quit
- in a text document: `↑`/`↓` scroll, `space` page down, `g` top
Non-members are refused with a pointer to `ssh join@` and the public gopher URL.
## Menu map
Both surfaces expose the same selector tree; hedgehog additionally resolves the
members-only parts.
```
/ root menu
/about brand + MOTD + how to connect (text)
/members directory → each member's homepage
/~<name>[/path] a member's homepage (their public_html — the same
pages served at https://bbs.profullstack.com/~name)
/news newsgroups (public groups only unless authenticated)
/news/<group> article list (newest first)
/news/<group>/<num> one article (text)
/files directory → each member's public files area
/files/~<name>[/path] a member's public files (their /public SFTP area)
```
Directories render as gopher menus; files are served by type (text vs binary vs
image). Path selectors are confined to the member's own area — any `..` that
would escape is neutralised and the resolved target is re-checked against the
base directory before anything is opened.
### News: public vs members-only
The news network is otherwise members-only (see [news.md](news.md)), so on the
**public** `:70` surface only an allowlist of groups is reachable
(`AGENTBBS_GOPHER_NEWS_GROUPS`, default `pfs.announce`). Every group is visible
on **hedgehog**, where you're authenticated. A public request for a non-allowed
group is refused with a hint to `ssh gopher@`.
## Configuration (env)
| Var | Default | Meaning |
|---|---|---|
| `AGENTBBS_GOPHER` | `1` | enable the gopher service (`0` disables both surfaces) |
| `AGENTBBS_GOPHER_ADDR` | `:70` | public RFC-1436 listener address |
| `AGENTBBS_GOPHER_HOST` | `gopher.<host w/o "bbs.">` | hostname advertised in menu selector rows |
| `AGENTBBS_GOPHER_NEWS_GROUPS` | `pfs.announce` | groups exposed on the public surface (all groups show on hedgehog) |
## Deploy note — binding port 70
`:70` is privileged (< 1024), just like the news `:563` NNTPS port, and **gopher
is not HTTP**, so Caddy's reverse proxy can't front it. Grant the binary the bind
capability:
```bash
sudo setcap 'cap_net_bind_service=+ep' /path/to/agentbbs
```
Or run the listener on a high port and redirect with the firewall:
```bash
AGENTBBS_GOPHER_ADDR=:7070 ./agentbbs
sudo iptables -t nat -A PREROUTING -p tcp --dport 70 -j REDIRECT --to-port 7070
```
hedgehog (`ssh gopher@`) needs none of this — it rides the existing SSH port.
## Why in-process
Same reasoning as [news](news.md): a full separate gopher daemon plus a
content-export pipeline would duplicate the member directory, homepage storage,
and store the BBS already owns. Resolving selectors directly against the live
store and the on-disk `public_html`/`public` areas keeps the two surfaces always
in sync with the rest of the BBS, for a protocol whose entire spec fits on a
few pages.

View file

@ -70,6 +70,12 @@ var NewsNames = map[string]bool{"news": true}
// an interactive TUI with a PTY, or a JSON bot mode with a command/no PTY. // an interactive TUI with a PTY, or a JSON bot mode with a command/no PTY.
var MailNames = map[string]bool{"mail": true} var MailNames = map[string]bool{"mail": true}
// GopherNames route a member into "hedgehog": the SSH-authenticated gopher
// browser (see internal/gopher). Free for any registered member, like news@.
// The public, unauthenticated Gopher surface is the port-70 listener, not a
// route here (RFC 1436 has no auth verb — see internal/gopher for the rationale).
var GopherNames = map[string]bool{"gopher": true}
// FilesAdminNames route an operator into the SFTP server management TUI. Members // FilesAdminNames route an operator into the SFTP server management TUI. Members
// transfer files via the "sftp" subsystem (sftp files@<host>); this interactive // transfer files via the "sftp" subsystem (sftp files@<host>); this interactive
// route is the operator console and is gated by the admin allowlist. // route is the operator console and is gated by the admin allowlist.
@ -109,6 +115,10 @@ func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] }
// IsMailName reports whether the SSH username requests the AgentMail client. // IsMailName reports whether the SSH username requests the AgentMail client.
func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] } func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] }
// IsGopherName reports whether the SSH username requests the hedgehog gopher
// browser (the SSH-authenticated gopher surface).
func IsGopherName(u string) bool { return GopherNames[strings.ToLower(u)] }
// IsFilesAdminName reports whether the SSH username requests the SFTP server // IsFilesAdminName reports whether the SSH username requests the SFTP server
// management TUI (operator-gated). // management TUI (operator-gated).
func IsFilesAdminName(u string) bool { return FilesAdminNames[strings.ToLower(u)] } func IsFilesAdminName(u string) bool { return FilesAdminNames[strings.ToLower(u)] }
@ -147,7 +157,7 @@ func IsReservedName(name string) bool {
n := strings.ToLower(name) n := strings.ToLower(name)
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] || if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] || TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] ||
MailNames[n] || FilesAdminNames[n] || MsgNames[n] || GameNames[n] || MailNames[n] || GopherNames[n] || FilesAdminNames[n] || MsgNames[n] || GameNames[n] ||
PasswdNames[n] || systemReserved[n] { PasswdNames[n] || systemReserved[n] {
return true return true
} }

250
internal/gopher/browser.go Normal file
View file

@ -0,0 +1,250 @@
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
}

116
internal/gopher/gopher.go Normal file
View file

@ -0,0 +1,116 @@
// Package gopher serves AgentBBS content over the Gopher protocol (RFC 1436)
// and its SSH-authenticated sibling, "hedgehog".
//
// # Two surfaces, one engine
//
// Every AgentBBS protocol service exposes a native port for external clients
// plus a zero-install `ssh <name>@` TUI for members (see internal/news). Gopher
// follows the same shape — but with a twist forced by the protocol itself:
//
// - Classic Gopher (RFC 1436) is a stateless, unauthenticated, one-shot TCP
// protocol on port 70: the client sends a single selector line and reads one
// response. There is no auth verb (no NNTP-style AUTHINFO), so SSH-key auth
// cannot be bolted onto a port-70 gopher server without breaking every
// gopher client. The public listener (Serve) therefore serves *public*
// content only.
//
// - "hedgehog" is the same gopher wire semantics carried over the SSH channel
// (RunBrowser), with the member's SSH key as the credential. Because the
// session is already authenticated, hedgehog additionally resolves
// members-only selectors (e.g. private newsgroups). It is the home-grown,
// gopher-like, SSH-authenticated surface — gopher where gopher can, our own
// thing where it can't.
//
// Both surfaces share one Resolve engine (server.go); the only difference is the
// authed flag passed in. The engine is read-only: nothing is ever written to the
// BBS over gopher.
package gopher
import "strings"
// Gopher item types (RFC 1436 §3.8). Only the handful the BBS emits are named.
const (
TypeText = '0' // a plain text file
TypeMenu = '1' // a Gopher directory (submenu)
TypeError = '3' // an error / "does not exist" line
TypeBinary = '9' // a generic binary file
TypeHTML = 'h' // an HTML file / URL: link
TypeImage = 'I' // an image
TypeInfo = 'i' // an informational line (not selectable)
)
// An Item is one gopher directory row. Info rows (TypeInfo) are display-only;
// every other type is a selectable link the client may follow to Selector on
// Host:Port. Keeping menus as structured Items (rather than pre-rendered text)
// lets the hedgehog browser TUI navigate them while the native listener renders
// the exact same rows to the wire.
type Item struct {
Type byte
Display string
Selector string
Host string
Port string
}
// A Menu accumulates gopher directory rows and renders them in the wire format:
// each row is "<type><display>\t<selector>\t<host>\t<port>\r\n" and the whole
// menu is terminated by a lone "." line (the RFC 1436 last-line dot).
type Menu struct {
host string
port string
Items []Item
}
// NewMenu starts a menu whose links advertise host:port as the server to dial
// for each selector (mirrored back to clients so link-following works).
func NewMenu(host, port string) *Menu {
return &Menu{host: host, port: port}
}
// Info appends one or more informational lines (type 'i'), split on newlines so
// callers can pass a multi-line block.
func (m *Menu) Info(text string) *Menu {
for _, line := range strings.Split(text, "\n") {
m.Items = append(m.Items, Item{Type: TypeInfo, Display: line, Host: "error.host", Port: "1"})
}
return m
}
// Link appends a selectable row of the given item type pointing at selector on
// this server.
func (m *Menu) Link(itemType byte, display, selector string) *Menu {
m.Items = append(m.Items, Item{Type: itemType, Display: display, Selector: selector, Host: m.host, Port: m.port})
return m
}
// URL appends an "URL:" link (type 'h') to an external http(s) address — the
// widely-supported convention for linking off-gopher.
func (m *Menu) URL(display, url string) *Menu {
m.Items = append(m.Items, Item{Type: TypeHTML, Display: display, Selector: "URL:" + url, Host: m.host, Port: m.port})
return m
}
// Len reports how many rows have been added (informational + selectable).
func (m *Menu) Len() int { return len(m.Items) }
// Render returns the full menu including the terminating "." line, CRLF-framed.
func (m *Menu) Render() string {
var b strings.Builder
for _, it := range m.Items {
b.WriteString(renderItem(it))
}
b.WriteString(".\r\n")
return b.String()
}
// renderItem formats one gopher directory entry. Tabs and CRs in the display or
// selector would corrupt the tab-delimited framing, so they are stripped.
func renderItem(it Item) string {
return string(it.Type) + clean(it.Display) + "\t" + clean(it.Selector) + "\t" +
it.Host + "\t" + it.Port + "\r\n"
}
// clean removes the framing-significant bytes (tab, CR, LF) from a field.
func clean(s string) string {
return strings.NewReplacer("\t", " ", "\r", "", "\n", " ").Replace(s)
}

View file

@ -0,0 +1,238 @@
package gopher
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/profullstack/agentbbs/internal/store"
)
// fakeContent is an in-memory Content for the resolver tests (no real store).
type fakeContent struct {
users []store.User
groups []store.NewsGroup
articles map[string][]store.NewsArticle
}
func (f *fakeContent) ListUsers(int) ([]store.User, error) { return f.users, nil }
func (f *fakeContent) NewsGroups() ([]store.NewsGroup, error) { return f.groups, nil }
func (f *fakeContent) NewsArticlesRange(g string, from, to int64) ([]store.NewsArticle, error) {
var out []store.NewsArticle
for _, a := range f.articles[g] {
if a.Num >= from && a.Num <= to {
out = append(out, a)
}
}
return out, nil
}
func (f *fakeContent) NewsArticleByNum(g string, num int64) (store.NewsArticle, bool, error) {
for _, a := range f.articles[g] {
if a.Num == num {
return a, true, nil
}
}
return store.NewsArticle{}, false, nil
}
// newTestServer wires a Server over a temp data dir with two members (one
// banned), two newsgroups (one public), and a homepage tree for alice.
func newTestServer(t *testing.T) (*Server, string) {
t.Helper()
dataDir := t.TempDir()
// alice's homepage: index.html (text) + assets/logo.png (binary) + subdir.
home := filepath.Join(dataDir, "users", "alice", "public_html")
mustMkdir(t, filepath.Join(home, "assets"))
mustWrite(t, filepath.Join(home, "index.html"), "<h1>hi</h1>")
mustWrite(t, filepath.Join(home, "assets", "logo.png"), "\x89PNGbinary")
// a file OUTSIDE any member area, to prove traversal cannot reach it.
mustWrite(t, filepath.Join(dataDir, "secret.txt"), "TOP SECRET")
fc := &fakeContent{
users: []store.User{
{Name: "alice"},
{Name: "bob", Banned: true},
{Name: "carol"},
},
groups: []store.NewsGroup{
{Name: "pfs.announce", Description: "Announcements", High: 1},
{Name: "pfs.secret", Description: "Members only", High: 1},
},
articles: map[string][]store.NewsArticle{
"pfs.announce": {{Group: "pfs.announce", Num: 1, Subject: "Welcome", From: "sysop", Body: "hello\n.\nworld"}},
"pfs.secret": {{Group: "pfs.secret", Num: 1, Subject: "Hush", From: "sysop", Body: "shh"}},
},
}
srv := New(fc, dataDir, "gopher.test", "70", []string{"pfs.announce"},
func() string { return "About body here" })
return srv, dataDir
}
func mustMkdir(t *testing.T, p string) {
t.Helper()
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatal(err)
}
}
func mustWrite(t *testing.T, p, s string) {
t.Helper()
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
func TestMenuWireFormat(t *testing.T) {
m := NewMenu("h.test", "70")
m.Info("hi").Link(TypeMenu, "Members", "/members")
got := m.Render()
// Info row is type 'i'; the link row is tab-delimited with host/port; the
// whole thing ends in the RFC 1436 dot line.
if !strings.HasPrefix(got, "ihi\t") {
t.Errorf("info row missing/malformed: %q", got)
}
if !strings.Contains(got, "1Members\t/members\th.test\t70\r\n") {
t.Errorf("link row malformed: %q", got)
}
if !strings.HasSuffix(got, ".\r\n") {
t.Errorf("menu must end with the dot terminator: %q", got)
}
}
func TestResolveRootBanner(t *testing.T) {
srv, _ := newTestServer(t)
pub := srv.Resolve("", false, "")
if pub.Kind != KindMenu {
t.Fatalf("root should be a menu, got %v", pub.Kind)
}
wire := string(pub.Wire())
for _, want := range []string{"/about", "/members", "/news", "/files", "ssh gopher@gopher.test"} {
if !strings.Contains(wire, want) {
t.Errorf("public root missing %q\n%s", want, wire)
}
}
authed := string(srv.Resolve("/", true, "alice").Wire())
if !strings.Contains(authed, "Signed in as alice") {
t.Errorf("authed root should greet the member:\n%s", authed)
}
}
func TestResolveAbout(t *testing.T) {
srv, _ := newTestServer(t)
r := srv.Resolve("/about", false, "")
if r.Kind != KindText || !strings.Contains(r.Text, "About body here") {
t.Fatalf("about wrong: %+v", r)
}
}
func TestResolveMembersExcludesBanned(t *testing.T) {
srv, _ := newTestServer(t)
wire := string(srv.Resolve("/members", false, "").Wire())
if !strings.Contains(wire, "\t/~alice\t") || !strings.Contains(wire, "\t/~carol\t") {
t.Errorf("members should list alice and carol:\n%s", wire)
}
if strings.Contains(wire, "/~bob") {
t.Errorf("banned member bob must not appear:\n%s", wire)
}
}
func TestHomepageFileAndDir(t *testing.T) {
srv, _ := newTestServer(t)
// directory listing
dir := srv.Resolve("/~alice", false, "")
if dir.Kind != KindMenu {
t.Fatalf("homepage root should be a dir menu, got %v", dir.Kind)
}
dw := string(dir.Wire())
if !strings.Contains(dw, "/~alice/index.html") || !strings.Contains(dw, "/~alice/assets") {
t.Errorf("dir listing missing entries:\n%s", dw)
}
// text file
txt := srv.Resolve("/~alice/index.html", false, "")
if txt.Kind != KindText || !strings.Contains(txt.Text, "<h1>hi</h1>") {
t.Errorf("index.html should be served as text: %+v", txt)
}
// binary file
bin := srv.Resolve("/~alice/assets/logo.png", false, "")
if bin.Kind != KindBinary || string(bin.Data) != "\x89PNGbinary" {
t.Errorf("png should be binary: %+v", bin)
}
}
func TestPathTraversalRefused(t *testing.T) {
srv, _ := newTestServer(t)
for _, sel := range []string{
"/~alice/../../secret.txt",
"/~alice/../../../etc/passwd",
"/files/~alice/../../secret.txt",
"/~alice/assets/../../../secret.txt",
} {
r := srv.Resolve(sel, true, "alice")
if r.Kind == KindText && strings.Contains(r.Text, "TOP SECRET") {
t.Fatalf("traversal %q leaked the secret file", sel)
}
if r.Kind == KindBinary && strings.Contains(string(r.Data), "TOP SECRET") {
t.Fatalf("traversal %q leaked the secret file (binary)", sel)
}
}
// A bad member name is refused outright.
if r := srv.Resolve("/~..", false, ""); r.Kind != KindError {
t.Errorf("bad member name should error, got %v", r.Kind)
}
}
func TestNewsPublicVsAuthed(t *testing.T) {
srv, _ := newTestServer(t)
// Public group list shows only the allowlisted group.
pub := string(srv.Resolve("/news", false, "").Wire())
if !strings.Contains(pub, "/news/pfs.announce") {
t.Errorf("public news should list pfs.announce:\n%s", pub)
}
if strings.Contains(pub, "/news/pfs.secret") {
t.Errorf("public news must hide pfs.secret:\n%s", pub)
}
// Authed group list shows every group.
authed := string(srv.Resolve("/news", true, "alice").Wire())
if !strings.Contains(authed, "/news/pfs.secret") {
t.Errorf("authed news should list pfs.secret:\n%s", authed)
}
// Public access to a members-only group is refused...
if r := srv.Resolve("/news/pfs.secret", false, ""); r.Kind != KindError {
t.Errorf("public access to members-only group should error, got %v", r.Kind)
}
// ...but the authenticated surface can read it.
if r := srv.Resolve("/news/pfs.secret/1", true, "alice"); r.Kind != KindText || !strings.Contains(r.Text, "shh") {
t.Errorf("authed should read the private article: %+v", r)
}
}
func TestArticleDotStuffing(t *testing.T) {
srv, _ := newTestServer(t)
r := srv.Resolve("/news/pfs.announce/1", false, "")
if r.Kind != KindText {
t.Fatalf("article should be text, got %v", r.Kind)
}
// The body has a lone "." line; on the wire it must be escaped to ".." so it
// is not read as the terminator, and the real terminator is the final line.
wire := string(r.Wire())
if !strings.Contains(wire, "\r\n..\r\n") {
t.Errorf("lone dot line should be dot-stuffed to '..':\n%q", wire)
}
if !strings.HasSuffix(wire, "\r\n.\r\n") {
t.Errorf("text response must end with the dot terminator:\n%q", wire)
}
}
func TestUnknownSelector(t *testing.T) {
srv, _ := newTestServer(t)
r := srv.Resolve("/does/not/exist", false, "")
if r.Kind != KindError {
t.Fatalf("unknown selector should error, got %v", r.Kind)
}
if !strings.HasPrefix(string(r.Wire()), "3") {
t.Errorf("error response should be a type-3 line: %q", r.Wire())
}
}

View file

@ -0,0 +1,62 @@
package gopher
import (
"bufio"
"context"
"io"
"net"
"strings"
"time"
"github.com/charmbracelet/log"
)
// maxSelector caps the selector line a client may send. RFC 1436 selectors are
// short; a generous bound stops a peer from streaming forever.
const maxSelector = 4096
// Serve runs the public Gopher listener (RFC 1436) on addr until ctx is
// cancelled. Every connection is a single transaction: read one CRLF-terminated
// selector, resolve it with authed=false (public content only), write the
// response, close. It is read-only — nothing is ever written to the BBS here.
func (s *Server) Serve(ctx context.Context, addr string) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
go func() { <-ctx.Done(); _ = ln.Close() }()
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
return err
}
}
go s.handleConn(conn)
}
}
// handleConn serves one gopher transaction.
func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
br := bufio.NewReader(&io.LimitedReader{R: conn, N: maxSelector})
line, err := br.ReadString('\n')
if err != nil && line == "" {
return
}
sel := strings.TrimRight(line, "\r\n")
// A gopher client may append "\t$" (Gopher+) — we speak plain RFC 1436, so
// keep only the selector up to the first tab.
if i := strings.IndexByte(sel, '\t'); i >= 0 {
sel = sel[:i]
}
resp := s.Resolve(sel, false, "")
if _, err := conn.Write(resp.Wire()); err != nil {
log.Debug("gopher write", "err", err)
}
}

450
internal/gopher/server.go Normal file
View file

@ -0,0 +1,450 @@
package gopher
import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/profullstack/agentbbs/internal/store"
)
// Content is the read-only slice of the store the gopher engine needs. It is
// satisfied directly by store.Store, so no new store methods are required; tests
// supply a small fake.
type Content interface {
ListUsers(limit int) ([]store.User, error)
NewsGroups() ([]store.NewsGroup, error)
NewsArticlesRange(group string, from, to int64) ([]store.NewsArticle, error)
NewsArticleByNum(group string, num int64) (store.NewsArticle, bool, error)
}
// Server resolves gopher selectors into responses over BBS content. It is shared
// by the public listener (Serve) and the hedgehog SSH browser (RunBrowser); the
// authed flag on Resolve is the only behavioural difference between them.
type Server struct {
c Content
dataDir string // <data>; homepages live at <data>/users/<name>/public_html
host string // hostname advertised in menu selector rows
port string // port advertised in menu selector rows (e.g. "70")
pubGroups map[string]bool // newsgroups exposed on the public (unauthenticated) surface
aboutFn func() string // body of the /about text page (brand + MOTD + connect help)
}
// New builds a Server. host/port are echoed into menu rows so gopher clients can
// follow links. pubGroups is the allowlist of newsgroups visible without auth
// (the authenticated hedgehog surface sees them all). aboutFn supplies the
// /about page body and may be nil.
func New(c Content, dataDir, host, port string, pubGroups []string, aboutFn func() string) *Server {
pg := make(map[string]bool, len(pubGroups))
for _, g := range pubGroups {
if g = strings.TrimSpace(g); g != "" {
pg[g] = true
}
}
if port == "" {
port = "70"
}
return &Server{c: c, dataDir: dataDir, host: host, port: port, pubGroups: pg, aboutFn: aboutFn}
}
// Kind classifies a resolved response.
type Kind int
const (
KindMenu Kind = iota // a gopher directory (Menu set)
KindText // a text document (Text set)
KindBinary // a binary file (Data set)
KindError // selector unknown/refused (Text set to the message)
)
// Response is the result of resolving a selector. Wire() renders it to the exact
// bytes the native listener sends; the TUI reads Kind/Menu/Text directly.
type Response struct {
Kind Kind
Menu *Menu // KindMenu
Text string // KindText, KindError
Data []byte // KindBinary
}
// Wire renders the response to the gopher wire format: menus and text end with
// the RFC 1436 "." terminator (text is dot-stuffed); binaries are sent raw.
func (r Response) Wire() []byte {
switch r.Kind {
case KindMenu:
return []byte(r.Menu.Render())
case KindText:
return []byte(dotStuff(r.Text) + ".\r\n")
case KindBinary:
return r.Data
default: // KindError
return []byte(renderItem(Item{Type: TypeError, Display: r.Text, Host: "error.host", Port: "1"}) + ".\r\n")
}
}
// Resolve maps a gopher selector to a response. authed is true only on the
// hedgehog (SSH-authenticated) surface, where member is the connecting member's
// name; the public listener always passes authed=false. The engine never writes.
func (s *Server) Resolve(selector string, authed bool, member string) Response {
sel := strings.Trim(selector, " \t\r\n")
sel = strings.TrimPrefix(sel, "/")
if sel == "" {
return s.root(authed, member)
}
head, rest := split1(sel)
switch head {
case "about":
return Response{Kind: KindText, Text: s.about()}
case "members":
return s.members()
case "news":
return s.news(rest, authed)
case "files":
if rest == "" {
return s.filesRoot()
}
return s.userTree("files", rest, "public")
default:
// "~name[/sub]" — a member homepage under public_html.
if strings.HasPrefix(head, "~") {
return s.userTree("", sel, "public_html")
}
return errResp("selector not found: " + selector)
}
}
// root is the top-level menu. It is identical on both surfaces except the
// authenticated banner and the fact that /news lists private groups too.
func (s *Server) root(authed bool, member string) Response {
m := NewMenu(s.host, s.port)
m.Info("AgentBBS over Gopher")
m.Info("A terminal BBS for humans & AI agents.")
if authed {
m.Info("Signed in as " + member + " (hedgehog: private groups visible).")
} else {
m.Info("Public view. For members-only content: ssh gopher@" + s.host)
}
m.Info("")
m.Link(TypeText, "About AgentBBS", "/about")
m.Link(TypeMenu, "Members — homepages & directory", "/members")
m.Link(TypeMenu, "News — public newsgroups", "/news")
m.Link(TypeMenu, "Files — members' public files", "/files")
return Response{Kind: KindMenu, Menu: m}
}
// about returns the /about text body (aboutFn, or a minimal fallback).
func (s *Server) about() string {
if s.aboutFn != nil {
if t := strings.TrimRight(s.aboutFn(), "\n"); t != "" {
return t + "\n"
}
}
return "AgentBBS — a terminal BBS for humans & AI agents.\n" +
"Connect: ssh " + trimBBS(s.host) + " · gopher://" + s.host + "\n"
}
// members lists every non-banned account, each linking to its homepage tree.
func (s *Server) members() Response {
users, err := s.c.ListUsers(1000)
if err != nil {
return errResp("members unavailable")
}
sort.Slice(users, func(i, j int) bool { return users[i].Name < users[j].Name })
m := NewMenu(s.host, s.port)
m.Info("Member homepages — the same pages served at https://" + s.host + "/~name")
m.Info("")
for _, u := range users {
if u.Banned || u.Name == "" {
continue
}
m.Link(TypeMenu, u.Name, "/~"+u.Name)
}
if m.Len() <= 2 {
m.Info("(no members yet)")
}
return Response{Kind: KindMenu, Menu: m}
}
// news serves the newsgroup tree. rest is "" (group list), "<group>" (article
// list), or "<group>/<num>" (one article). On the public surface only groups in
// pubGroups are reachable; hedgehog (authed) reaches every group.
func (s *Server) news(rest string, authed bool) Response {
if rest == "" {
groups, err := s.c.NewsGroups()
if err != nil {
return errResp("news unavailable")
}
m := NewMenu(s.host, s.port)
if authed {
m.Info("Newsgroups (all groups — you are authenticated)")
} else {
m.Info("Public newsgroups. The rest are members-only: ssh gopher@" + s.host)
}
m.Info("")
for _, g := range groups {
if !authed && !s.pubGroups[g.Name] {
continue
}
label := g.Name
if g.Description != "" {
label += " — " + g.Description
}
m.Link(TypeMenu, label, "/news/"+g.Name)
}
if m.Len() <= 2 {
m.Info("(no public groups)")
}
return Response{Kind: KindMenu, Menu: m}
}
group, tail := split1(rest)
if !authed && !s.pubGroups[group] {
return errResp("group is members-only: ssh gopher@" + s.host)
}
if tail == "" {
return s.newsArticles(group)
}
num, err := strconv.ParseInt(tail, 10, 64)
if err != nil {
return errResp("bad article number")
}
a, ok, err := s.c.NewsArticleByNum(group, num)
if err != nil || !ok {
return errResp("article not found")
}
var b strings.Builder
b.WriteString("From: " + a.From + "\n")
b.WriteString("Subject: " + a.Subject + "\n")
b.WriteString("Date: " + a.Date + "\n")
b.WriteString("Group: " + group + " #" + tail + "\n")
b.WriteString(strings.Repeat("-", 64) + "\n\n")
b.WriteString(a.Body)
return Response{Kind: KindText, Text: b.String()}
}
// newsArticles lists the articles in a group, newest first.
func (s *Server) newsArticles(group string) Response {
gs, err := s.c.NewsGroups()
if err != nil {
return errResp("news unavailable")
}
var high int64
found := false
for _, g := range gs {
if g.Name == group {
high, found = g.High, true
break
}
}
if !found {
return errResp("no such group")
}
arts, err := s.c.NewsArticlesRange(group, 1, high)
if err != nil {
return errResp("group unavailable")
}
sort.Slice(arts, func(i, j int) bool { return arts[i].Num > arts[j].Num })
m := NewMenu(s.host, s.port)
m.Info(group)
m.Info("")
for _, a := range arts {
label := "#" + strconv.FormatInt(a.Num, 10) + " " + a.Subject
if a.From != "" {
label += " (" + a.From + ")"
}
m.Link(TypeText, label, "/news/"+group+"/"+strconv.FormatInt(a.Num, 10))
}
if m.Len() <= 2 {
m.Info("(no articles yet)")
}
return Response{Kind: KindMenu, Menu: m}
}
// filesRoot lists every non-banned member, each linking to their public files
// area (<data>/users/<name>/public), mirroring the anonymous web files surface.
func (s *Server) filesRoot() Response {
users, err := s.c.ListUsers(1000)
if err != nil {
return errResp("files unavailable")
}
sort.Slice(users, func(i, j int) bool { return users[i].Name < users[j].Name })
m := NewMenu(s.host, s.port)
m.Info("Members' public files")
m.Info("")
for _, u := range users {
if u.Banned || u.Name == "" {
continue
}
m.Link(TypeMenu, u.Name, "/files/~"+u.Name)
}
if m.Len() <= 2 {
m.Info("(no members yet)")
}
return Response{Kind: KindMenu, Menu: m}
}
// userTree serves a member's per-user area (public_html homepage, or the public
// files area) as gopher content. prefix is the selector root ("" for homepages,
// "files" for the files area); sel is the remainder after the prefix, of the form
// "~name[/subpath]". area is the on-disk subdirectory under <data>/users/<name>.
//
// The subpath is confined to the member's area: it is cleaned as an absolute
// path (so any ".." that would escape is neutralised) and the resolved target is
// re-checked to live under the base directory before any file is opened.
func (s *Server) userTree(prefix, sel, area string) Response {
// sel is "~name[/subpath]" for both surfaces (Resolve passes the slice after
// any "/files" head), so the first segment is always the member.
tilde, sub := split1(sel)
name := strings.TrimPrefix(tilde, "~")
if !validName(name) {
return errResp("bad member name")
}
base := filepath.Join(s.dataDir, "users", name, area)
// Confine sub to base. Cleaning as an absolute path drops any leading ".."
// components; the HasPrefix re-check is belt-and-suspenders against symlink
// or edge cases.
clean := filepath.Clean("/" + sub)
target := filepath.Join(base, clean)
if target != base && !strings.HasPrefix(target, base+string(os.PathSeparator)) {
return errResp("forbidden")
}
info, err := os.Stat(target)
if err != nil {
return errResp("not found")
}
linkBase := "/~" + name
if prefix == "files" {
linkBase = "/files/~" + name
}
rel := strings.TrimPrefix(strings.TrimPrefix(target, base), string(os.PathSeparator))
if info.IsDir() {
return s.dirMenu(target, linkBase, rel, name)
}
data, err := os.ReadFile(target)
if err != nil {
return errResp("unreadable")
}
if isText(target) {
return Response{Kind: KindText, Text: string(data)}
}
return Response{Kind: KindBinary, Data: data}
}
// dirMenu renders a directory listing. linkBase is the selector prefix for this
// member's area ("/~name" or "/files/~name"); rel is the directory's path within
// the area ("" at the area root).
func (s *Server) dirMenu(dir, linkBase, rel, name string) Response {
entries, err := os.ReadDir(dir)
if err != nil {
return errResp("unreadable")
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].IsDir() != entries[j].IsDir() {
return entries[i].IsDir() // directories first
}
return entries[i].Name() < entries[j].Name()
})
m := NewMenu(s.host, s.port)
title := name
if rel != "" {
title += "/" + rel
}
m.Info(title)
m.Info("")
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") {
continue // hidden files
}
childSel := linkBase
if rel != "" {
childSel += "/" + rel
}
childSel += "/" + e.Name()
if e.IsDir() {
m.Link(TypeMenu, e.Name()+"/", childSel)
continue
}
t := byte(TypeBinary)
if isText(e.Name()) {
t = TypeText
} else if isImage(e.Name()) {
t = TypeImage
}
m.Link(t, e.Name(), childSel)
}
if m.Len() <= 2 {
m.Info("(empty)")
}
return Response{Kind: KindMenu, Menu: m}
}
// --- helpers ---
// split1 splits "a/b/c" into ("a", "b/c"); a bare "a" yields ("a", "").
func split1(p string) (head, rest string) {
p = strings.TrimPrefix(p, "/")
if i := strings.IndexByte(p, '/'); i >= 0 {
return p[:i], p[i+1:]
}
return p, ""
}
// validName accepts the member-name charset (see auth.SanitizeUsername) and
// nothing that could traverse or escape a path.
func validName(name string) bool {
if name == "" || len(name) > 32 {
return false
}
for _, r := range name {
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
return false
}
}
return true
}
func errResp(msg string) Response { return Response{Kind: KindError, Text: msg} }
// trimBBS drops a leading "bbs." so connect hints read "profullstack.com".
func trimBBS(host string) string { return strings.TrimPrefix(host, "bbs.") }
// isText reports whether a filename should be served as a gopher text document.
func isText(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".txt", ".md", ".markdown", ".html", ".htm", ".css", ".js", ".json",
".xml", ".gmi", ".gph", ".log", ".csv", ".org", ".rst", "":
return true
}
return false
}
// isImage reports whether a filename is a common image (gopher type 'I').
func isImage(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".svg":
return true
}
return false
}
// dotStuff prepares a text body for the RFC 1436 "." terminator: it forces CRLF
// line endings and escapes any line consisting solely of "." to ".." so it is
// not mistaken for the end-of-response marker.
func dotStuff(text string) string {
text = strings.ReplaceAll(text, "\r\n", "\n")
lines := strings.Split(text, "\n")
for i, ln := range lines {
if ln == "." {
lines[i] = ".."
}
}
out := strings.Join(lines, "\r\n")
if !strings.HasSuffix(out, "\r\n") {
out += "\r\n"
}
return out
}