mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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>
358 lines
8.4 KiB
Go
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])
|
|
}
|