agentbbs/internal/gopher/server.go
RissRIce be75744248
fix(gopher): serve public files from SFTP storage (#115)
* test(gopher): cover SFTP public file paths

* fix(gopher): serve public files from SFTP storage
2026-08-12 19:20:06 -07:00

465 lines
14 KiB
Go

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>/files/public/<name>), 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]". Homepages live under <data>/users/<name>/<area>; public
// files share the SFTP service's <data>/files/public/<name> storage root.
//
// 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)
if prefix == "files" {
base = filepath.Join(s.dataDir, "files", "public", name)
}
// Confine sub to base. Cleaning as an absolute path drops any leading ".."
// components; the HasPrefix re-check is belt-and-suspenders against edge
// cases before resolving symlinks below.
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")
}
baseReal, err := filepath.EvalSymlinks(base)
if err != nil {
return errResp("not found")
}
targetReal, err := filepath.EvalSymlinks(target)
if err != nil {
return errResp("not found")
}
if targetReal != baseReal && !strings.HasPrefix(targetReal, baseReal+string(os.PathSeparator)) {
return errResp("forbidden")
}
linkBase := "/~" + name
if prefix == "files" {
linkBase = "/files/~" + name
}
rel := strings.TrimPrefix(strings.TrimPrefix(target, base), string(os.PathSeparator))
if info.IsDir() {
return s.dirMenu(targetReal, linkBase, rel, name)
}
data, err := os.ReadFile(targetReal)
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
}