agentbbs/internal/files/tui.go
Anthony Ettinger 6dc94bd784 feat(files): SFTP member storage — private workspaces + shared public area + mgmt TUI
Implements M4 (Files). A fully virtual Go SFTP server (pkg/sftp + crypto/ssh,
no OS users) wired as an "sftp" subsystem on the existing :22 wish listener, so
members reach their files with their login key:

    sftp files@bbs.profullstack.com     # scp/rsync ride the same endpoint

Identity is the SSH key (the username is conventional/ignored). Two areas per
session: a private, quota-limited /me workspace and a single shared public file
area /public (old-school BBS file area; world-read, members-only write by
default, operator-moderated). This reverses the old NG1 "no sharing" boundary in
favour of one sanctioned, inspectable sharing surface (PRD §9.3 amended).

internal/files:
- backend.go  service, layout, quota/usage, live-session registry, operator API
- fs.go       per-session virtual FS; resolve() is the single security
              chokepoint (area confinement + symlink-escape guard) + pkg/sftp
              request handlers
- server.go   subsystem handler: key auth -> member session -> request server,
              with byte metering and force-disconnect
- tui.go      in-BBS member browser (hub plugin "Files")
- admin.go    operator management TUI: sessions, workspaces/quotas, public area

Operator console: ssh sftp@<host> (allowlist-gated; sftpadmin@/filesadmin@
aliases) — list/disconnect sessions, set per-user quotas, revoke SFTP access,
toggle public write, moderate the public area.

store: files_access (per-user quota override + revoked) and files_settings
(public-write mode) tables + methods. main.go wiring guarded by AGENTBBS_FILES
(+ AGENTBBS_FILES_QUOTA_MB, default 1 GiB). Route names reserved.

Tests (incl -race): path traversal/confinement, symlink-escape rejection,
public-write ACL, quota enforcement, usage accounting, and an end-to-end run
against a real SFTP client. Docs: docs/files.md; PRD §5.3/§5.3.1/§9.3 + README
updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:35:04 +00:00

358 lines
8.4 KiB
Go

package files
import (
"fmt"
"path"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/store"
)
// viewMax caps how much of a file the in-BBS viewer loads.
const viewMax = 64 << 10
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
dirStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#60a5fa")).Bold(true)
selStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("#4ade80"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f87171"))
)
// NewPlugin returns the in-BBS file browser plugin bound to svc.
func NewPlugin(svc *Service) plugin.Plugin { return browserPlugin{svc: svc} }
type browserPlugin struct{ svc *Service }
func (browserPlugin) ID() string { return "files" }
func (browserPlugin) Title() string { return "Files" }
func (browserPlugin) Description() string { return "Your SFTP workspace + the shared public area" }
func (browserPlugin) RequiresAuth() bool { return true }
func (p browserPlugin) New(user auth.User, _ plugin.Context) tea.Model {
sess, su, err := p.svc.OpenFor(user.Name)
m := browser{svc: p.svc, sess: sess, user: su, cwd: "/", host: "bbs.profullstack.com"}
if err != nil {
m.fatal = "could not open your workspace: " + err.Error()
return m
}
m.reload()
return m
}
type mode int
const (
modeBrowse mode = iota
modeView
modeInput
modeConfirm
)
type browser struct {
svc *Service
sess *session
user store.User
host string
cwd string
items []Entry
sel int
msg string
fatal string
mode mode
prompt string // input prompt label
input string // typed text
onInput func(string) // committed-input callback
confirm string // confirmation question
onYes func() // confirmed-action callback
viewName string
viewBody string
}
func (m *browser) reload() {
items, err := m.sess.entries(m.cwd)
if err != nil {
m.msg = warnStyle.Render("error: " + err.Error())
m.items = nil
return
}
m.items = items
if m.sel >= len(items) {
m.sel = len(items) - 1
}
if m.sel < 0 {
m.sel = 0
}
}
func (m browser) Init() tea.Cmd { return nil }
func (m browser) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
key, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
if m.fatal != "" {
return m, plugin.Exit
}
switch m.mode {
case modeView:
m.mode = modeBrowse
return m, nil
case modeInput:
return m.updateInput(key)
case modeConfirm:
return m.updateConfirm(key)
default:
return m.updateBrowse(key)
}
}
func (m browser) updateBrowse(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "q", "esc":
return m, plugin.Exit
case "up", "k":
if m.sel > 0 {
m.sel--
}
case "down", "j":
if m.sel < len(m.items)-1 {
m.sel++
}
case "left", "h", "backspace":
if m.cwd != "/" {
m.cwd = path.Dir(strings.TrimRight(m.cwd, "/"))
m.sel = 0
m.msg = ""
m.reload()
}
case "enter", "right", "l":
return m.open()
case "n":
if m.sess.canWrite(m.childPath("x")) {
m.startInput("new directory name: ", func(name string) {
if name = clean(name); name == "" {
return
}
if err := m.sess.mkdir(m.childPath(name)); err != nil {
m.msg = warnStyle.Render("mkdir: " + err.Error())
} else {
m.msg = "created " + name + "/"
}
m.reload()
})
} else {
m.msg = warnStyle.Render("this area is read-only")
}
case "r":
if cur := m.current(); cur != nil && m.sess.canWrite(m.childPath(cur.Name)) {
old := cur.Name
m.startInput("rename "+old+" to: ", func(name string) {
if name = clean(name); name == "" {
return
}
if err := m.sess.rename(m.childPath(old), m.childPath(name)); err != nil {
m.msg = warnStyle.Render("rename: " + err.Error())
} else {
m.msg = "renamed to " + name
}
m.reload()
})
} else {
m.msg = warnStyle.Render("nothing to rename here")
}
case "d":
if cur := m.current(); cur != nil && m.sess.canWrite(m.childPath(cur.Name)) {
name := cur.Name
m.startConfirm("delete "+name+"? (y/n)", func() {
if err := m.sess.remove(m.childPath(name)); err != nil {
m.msg = warnStyle.Render("delete: " + err.Error())
} else {
m.msg = "deleted " + name
}
m.reload()
})
} else {
m.msg = warnStyle.Render("nothing to delete here")
}
}
return m, nil
}
func (m browser) open() (tea.Model, tea.Cmd) {
cur := m.current()
if cur == nil {
return m, nil
}
target := m.childPath(cur.Name)
if cur.IsDir {
m.cwd = target
m.sel = 0
m.msg = ""
m.reload()
return m, nil
}
body, truncated, err := m.sess.readFile(target, viewMax)
if err != nil {
m.msg = warnStyle.Render("open: " + err.Error())
return m, nil
}
m.viewName = cur.Name
m.viewBody = string(body)
if truncated {
m.viewBody += "\n\n" + dimStyle.Render("… (truncated; download the full file over SFTP)")
}
m.mode = modeView
return m, nil
}
func (m browser) updateInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.Type {
case tea.KeyEnter:
m.mode = modeBrowse
if m.onInput != nil {
m.onInput(m.input)
}
m.input = ""
case tea.KeyEsc:
m.mode = modeBrowse
m.input = ""
case tea.KeyBackspace:
if m.input != "" {
m.input = m.input[:len(m.input)-1]
}
case tea.KeyRunes, tea.KeySpace:
m.input += string(key.Runes)
}
return m, nil
}
func (m browser) updateConfirm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "y", "Y":
m.mode = modeBrowse
if m.onYes != nil {
m.onYes()
}
case "n", "N", "esc":
m.mode = modeBrowse
m.msg = "cancelled"
}
return m, nil
}
func (m *browser) startInput(prompt string, cb func(string)) {
m.mode = modeInput
m.prompt = prompt
m.input = ""
m.onInput = cb
}
func (m *browser) startConfirm(q string, cb func()) {
m.mode = modeConfirm
m.confirm = q
m.onYes = cb
}
func (m browser) current() *Entry {
if m.sel < 0 || m.sel >= len(m.items) {
return nil
}
return &m.items[m.sel]
}
func (m browser) childPath(name string) string {
return path.Join(m.cwd, name)
}
func (m browser) View() string {
if m.fatal != "" {
return lipgloss.NewStyle().Padding(1, 2).Render(warnStyle.Render(m.fatal) + "\n\npress any key to return")
}
if m.mode == modeView {
return lipgloss.NewStyle().Padding(1, 2).Render(
titleStyle.Render("Files · "+m.viewName) + "\n\n" + m.viewBody +
"\n\n" + dimStyle.Render("press any key to go back"))
}
var b strings.Builder
usage, _ := m.svc.Usage(m.user)
b.WriteString(titleStyle.Render("Files") + " " + dimStyle.Render(m.cwd) + "\n")
b.WriteString(dimStyle.Render(fmt.Sprintf("workspace: %s / %s (%d%% used) · transfer: sftp files@%s\n\n",
humanBytes(usage.Bytes), humanBytes(usage.Quota), pct(usage.Bytes, usage.Quota), m.host)))
if len(m.items) == 0 {
b.WriteString(dimStyle.Render(" (empty)\n"))
}
for i, e := range m.items {
name := e.Name
meta := humanBytes(e.Size)
if e.IsDir {
name += "/"
meta = "dir"
}
line := fmt.Sprintf(" %-28s %8s", name, meta)
switch {
case i == m.sel:
b.WriteString(selStyle.Render(line))
case e.IsDir:
b.WriteString(dirStyle.Render(line))
default:
b.WriteString(line)
}
b.WriteString("\n")
}
b.WriteString("\n")
switch m.mode {
case modeInput:
b.WriteString(titleStyle.Render(m.prompt) + m.input + "_\n")
case modeConfirm:
b.WriteString(warnStyle.Render(m.confirm) + "\n")
default:
if m.msg != "" {
b.WriteString(m.msg + "\n")
}
b.WriteString(dimStyle.Render("↑/↓ move · enter open · n new dir · r rename · d delete · ←/bksp up · q quit"))
}
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
// clean trims a user-typed file/dir name to a single safe path segment.
func clean(s string) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "/", "")
if s == "." || s == ".." {
return ""
}
return s
}
func pct(used, quota int64) int {
if quota <= 0 {
return 0
}
return int(used * 100 / quota)
}
// humanBytes formats a byte count compactly (e.g. 1.5G).
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%dB", n)
}
div, exp := int64(unit), 0
for x := n / unit; x >= unit; x /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%c", float64(n)/float64(div), "KMGTPE"[exp])
}