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 // ; homepages live at /users//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), "" (article // list), or "/" (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 (/users//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 /users/. // // 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 }