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>
339 lines
7.5 KiB
Go
339 lines
7.5 KiB
Go
package files
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/profullstack/agentbbs/internal/store"
|
|
)
|
|
|
|
// NewAdminModel returns the operator management TUI for the SFTP server. Gate
|
|
// the route that launches it on the operator allowlist (auth.IsAdmin).
|
|
func NewAdminModel(svc *Service) tea.Model {
|
|
m := admin{svc: svc}
|
|
m.reload()
|
|
return m
|
|
}
|
|
|
|
type adminTab int
|
|
|
|
const (
|
|
tabSessions adminTab = iota
|
|
tabWorkspaces
|
|
tabPublic
|
|
numTabs
|
|
)
|
|
|
|
var tabNames = []string{"Sessions", "Workspaces", "Public area"}
|
|
|
|
type admin struct {
|
|
svc *Service
|
|
tab adminTab
|
|
sel int
|
|
msg string
|
|
|
|
sessions []Conn
|
|
users []store.User
|
|
public []Entry
|
|
|
|
input bool
|
|
prompt string
|
|
buf string
|
|
onInput func(string)
|
|
}
|
|
|
|
func (m *admin) reload() {
|
|
m.sessions = m.svc.Sessions()
|
|
m.users, _ = m.svc.Users()
|
|
m.public, _ = m.svc.PublicList()
|
|
if m.sel < 0 {
|
|
m.sel = 0
|
|
}
|
|
}
|
|
|
|
func (m admin) Init() tea.Cmd { return nil }
|
|
|
|
func (m admin) rows() int {
|
|
switch m.tab {
|
|
case tabSessions:
|
|
return len(m.sessions)
|
|
case tabWorkspaces:
|
|
return len(m.users)
|
|
case tabPublic:
|
|
return len(m.public)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (m admin) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
key, ok := msg.(tea.KeyMsg)
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
if m.input {
|
|
return m.updateInput(key)
|
|
}
|
|
switch key.String() {
|
|
case "q", "esc":
|
|
return m, tea.Quit
|
|
case "tab", "right", "l":
|
|
m.tab = (m.tab + 1) % numTabs
|
|
m.sel, m.msg = 0, ""
|
|
case "shift+tab", "left", "h":
|
|
m.tab = (m.tab + numTabs - 1) % numTabs
|
|
m.sel, m.msg = 0, ""
|
|
case "up", "k":
|
|
if m.sel > 0 {
|
|
m.sel--
|
|
}
|
|
case "down", "j":
|
|
if m.sel < m.rows()-1 {
|
|
m.sel++
|
|
}
|
|
case "g":
|
|
m.reload()
|
|
m.msg = "refreshed"
|
|
default:
|
|
return m.action(key)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m admin) action(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch m.tab {
|
|
case tabSessions:
|
|
if key.String() == "x" {
|
|
if c := m.curSession(); c != nil {
|
|
if m.svc.Kick(c.ID) {
|
|
m.msg = "disconnected " + c.User
|
|
}
|
|
m.reload()
|
|
}
|
|
}
|
|
case tabWorkspaces:
|
|
switch key.String() {
|
|
case "Q":
|
|
if u := m.curUser(); u != nil {
|
|
uid := u.ID
|
|
m.startInput("quota MB for "+u.Name+" (0 = default): ", func(s string) {
|
|
mb, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
|
if err != nil || mb < 0 {
|
|
m.msg = warnStyle.Render("invalid number")
|
|
return
|
|
}
|
|
if err := m.svc.SetQuota(uid, mb<<20); err != nil {
|
|
m.msg = warnStyle.Render(err.Error())
|
|
return
|
|
}
|
|
m.msg = fmt.Sprintf("quota set to %d MB", mb)
|
|
})
|
|
}
|
|
case "x":
|
|
if u := m.curUser(); u != nil {
|
|
fa, _ := m.svc.Access(u.ID)
|
|
if err := m.svc.SetRevoked(u.ID, !fa.Revoked); err != nil {
|
|
m.msg = warnStyle.Render(err.Error())
|
|
} else if fa.Revoked {
|
|
m.msg = "restored SFTP for " + u.Name
|
|
} else {
|
|
m.msg = "revoked SFTP for " + u.Name
|
|
}
|
|
}
|
|
}
|
|
case tabPublic:
|
|
switch key.String() {
|
|
case "t":
|
|
on := !m.svc.PublicWritable()
|
|
if err := m.svc.SetPublicWrite(on); err != nil {
|
|
m.msg = warnStyle.Render(err.Error())
|
|
} else if on {
|
|
m.msg = "public area is now writable (members)"
|
|
} else {
|
|
m.msg = "public area is now read-only"
|
|
}
|
|
case "x":
|
|
if e := m.curPublic(); e != nil {
|
|
name := e.Name
|
|
if err := m.svc.PublicRemove(name); err != nil {
|
|
m.msg = warnStyle.Render(err.Error())
|
|
} else {
|
|
m.msg = "removed " + name
|
|
}
|
|
m.reload()
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m admin) updateInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch key.Type {
|
|
case tea.KeyEnter:
|
|
m.input = false
|
|
if m.onInput != nil {
|
|
m.onInput(m.buf)
|
|
}
|
|
m.buf = ""
|
|
m.reload()
|
|
case tea.KeyEsc:
|
|
m.input, m.buf = false, ""
|
|
case tea.KeyBackspace:
|
|
if m.buf != "" {
|
|
m.buf = m.buf[:len(m.buf)-1]
|
|
}
|
|
case tea.KeyRunes, tea.KeySpace:
|
|
m.buf += string(key.Runes)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *admin) startInput(prompt string, cb func(string)) {
|
|
m.input, m.prompt, m.buf, m.onInput = true, prompt, "", cb
|
|
}
|
|
|
|
func (m admin) curSession() *Conn {
|
|
if m.tab != tabSessions || m.sel >= len(m.sessions) {
|
|
return nil
|
|
}
|
|
return &m.sessions[m.sel]
|
|
}
|
|
func (m admin) curUser() *store.User {
|
|
if m.tab != tabWorkspaces || m.sel >= len(m.users) {
|
|
return nil
|
|
}
|
|
return &m.users[m.sel]
|
|
}
|
|
func (m admin) curPublic() *Entry {
|
|
if m.tab != tabPublic || m.sel >= len(m.public) {
|
|
return nil
|
|
}
|
|
return &m.public[m.sel]
|
|
}
|
|
|
|
func (m admin) View() string {
|
|
var b strings.Builder
|
|
b.WriteString(titleStyle.Render("SFTP server — management") + "\n")
|
|
|
|
var tabs []string
|
|
for i, name := range tabNames {
|
|
if adminTab(i) == m.tab {
|
|
tabs = append(tabs, selStyle.Render(" "+name+" "))
|
|
} else {
|
|
tabs = append(tabs, dimStyle.Render(" "+name+" "))
|
|
}
|
|
}
|
|
b.WriteString(strings.Join(tabs, " ") + "\n\n")
|
|
|
|
switch m.tab {
|
|
case tabSessions:
|
|
b.WriteString(m.viewSessions())
|
|
case tabWorkspaces:
|
|
b.WriteString(m.viewWorkspaces())
|
|
case tabPublic:
|
|
b.WriteString(m.viewPublic())
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
if m.input {
|
|
b.WriteString(titleStyle.Render(m.prompt) + m.buf + "_\n")
|
|
} else {
|
|
if m.msg != "" {
|
|
b.WriteString(m.msg + "\n")
|
|
}
|
|
b.WriteString(dimStyle.Render(m.help()))
|
|
}
|
|
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
|
}
|
|
|
|
func (m admin) help() string {
|
|
common := "tab switch · ↑/↓ move · g refresh · q quit"
|
|
switch m.tab {
|
|
case tabSessions:
|
|
return "x disconnect · " + common
|
|
case tabWorkspaces:
|
|
return "Q set quota · x revoke/restore · " + common
|
|
case tabPublic:
|
|
return "t toggle write · x remove entry · " + common
|
|
}
|
|
return common
|
|
}
|
|
|
|
func (m admin) viewSessions() string {
|
|
if len(m.sessions) == 0 {
|
|
return dimStyle.Render(" no live SFTP connections")
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString(dimStyle.Render(fmt.Sprintf(" %-16s %-22s %10s %10s %8s\n", "user", "remote", "rx", "tx", "idle")))
|
|
for i, c := range m.sessions {
|
|
line := fmt.Sprintf(" %-16s %-22s %10s %10s %8s",
|
|
c.User, c.Remote, humanBytes(c.RX), humanBytes(c.TX), since(c.Started))
|
|
b.WriteString(rowStyle(i == m.sel).Render(line) + "\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (m admin) viewWorkspaces() string {
|
|
if len(m.users) == 0 {
|
|
return dimStyle.Render(" no members")
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString(dimStyle.Render(fmt.Sprintf(" %-16s %10s %10s %6s %s\n", "user", "used", "quota", "use%", "access")))
|
|
for i, u := range m.users {
|
|
usage, _ := m.svc.Usage(u)
|
|
fa, _ := m.svc.Access(u.ID)
|
|
access := "ok"
|
|
if fa.Revoked {
|
|
access = warnStyle.Render("revoked")
|
|
}
|
|
line := fmt.Sprintf(" %-16s %10s %10s %5d%% %s",
|
|
u.Name, humanBytes(usage.Bytes), humanBytes(usage.Quota), pct(usage.Bytes, usage.Quota), access)
|
|
b.WriteString(rowStyle(i == m.sel).Render(line) + "\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (m admin) viewPublic() string {
|
|
state := "writable (members)"
|
|
if !m.svc.PublicWritable() {
|
|
state = "read-only"
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString(dimStyle.Render(" public-area write: ") + state + "\n\n")
|
|
if len(m.public) == 0 {
|
|
b.WriteString(dimStyle.Render(" (empty)"))
|
|
return b.String()
|
|
}
|
|
for i, e := range m.public {
|
|
name := e.Name
|
|
meta := humanBytes(e.Size)
|
|
if e.IsDir {
|
|
name += "/"
|
|
meta = "dir"
|
|
}
|
|
b.WriteString(rowStyle(i == m.sel).Render(fmt.Sprintf(" %-28s %8s", name, meta)) + "\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func rowStyle(selected bool) lipgloss.Style {
|
|
if selected {
|
|
return selStyle
|
|
}
|
|
return lipgloss.NewStyle()
|
|
}
|
|
|
|
func since(t time.Time) string {
|
|
d := time.Since(t).Round(time.Second)
|
|
if d < time.Minute {
|
|
return fmt.Sprintf("%ds", int(d.Seconds()))
|
|
}
|
|
if d < time.Hour {
|
|
return fmt.Sprintf("%dm", int(d.Minutes()))
|
|
}
|
|
return fmt.Sprintf("%dh", int(d.Hours()))
|
|
}
|