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>
This commit is contained in:
Anthony Ettinger 2026-06-23 09:35:04 +00:00
parent 68899180a7
commit 6dc94bd784
18 changed files with 2223 additions and 27 deletions

339
internal/files/admin.go Normal file
View file

@ -0,0 +1,339 @@
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()))
}

317
internal/files/backend.go Normal file
View file

@ -0,0 +1,317 @@
// Package files implements member file storage for AgentBBS, reachable over
// SFTP with the member's existing SSH login key (docs/files.md). It is wired as
// an "sftp" subsystem on the shared wish server (port 22), so a member runs
//
// sftp files@bbs.profullstack.com
//
// and lands in a virtual filesystem with two areas:
//
// /me — their private, per-user workspace (quota-limited)
// /public — a single shared file area (old-school BBS file area); world-read,
// members-only write by default, operator-moderated.
//
// Identity is the SSH public key (one key = one account, like the rest of the
// BBS); the SFTP username is conventional ("files") and ignored. The server is a
// fully virtual Go SFTP server (github.com/pkg/sftp) — there are no OS users.
//
// Security: every path the client supplies is resolved through resolve() in
// fs.go, which confines it to its area root (no traversal, no symlink escape);
// see the path-traversal tests. Per §9.2 the operator can inspect and act on
// hosted files (the management TUI), and per §9.3 (amended) the only sharing
// surface is the single public area — there is no workspace-to-workspace
// transfer.
package files
import (
"io/fs"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/profullstack/agentbbs/internal/store"
)
// Setting keys persisted in files_settings.
const (
// settingPublicWrite is "members" (default) or "off". When "off", the
// shared public area is read-only for everyone (operators moderate via the
// management TUI / filesystem).
settingPublicWrite = "public_write"
)
// DefaultQuota is the per-user workspace quota when none is configured.
const DefaultQuota int64 = 1 << 30 // 1 GiB
// FilesStore is the slice of the store the Files service needs.
type FilesStore interface {
UserByFingerprint(fp string) (store.User, bool, error)
UserByName(name string) (store.User, bool, error)
ListUsers(limit int) ([]store.User, error)
FilesAccess(userID int64) (store.FilesAccess, error)
SetFilesQuota(userID, bytes int64) error
SetFilesRevoked(userID int64, revoked bool) error
FilesSetting(key string) (string, bool, error)
SetFilesSetting(key, value string) error
}
// Config configures the Files service.
type Config struct {
// Root is the storage root, e.g. <dataDir>/files. The service owns
// <Root>/users/<name> (private) and <Root>/public (shared).
Root string
// DefaultQuota is the per-user workspace quota in bytes (0 → DefaultQuota).
DefaultQuota int64
}
// Service is the shared Files engine: one instance backs the SFTP subsystem,
// the in-BBS browser, and the operator management TUI.
type Service struct {
st FilesStore
cfg Config
reg *registry
}
// New builds a Files service and ensures the storage layout exists.
func New(st FilesStore, cfg Config) (*Service, error) {
if cfg.DefaultQuota <= 0 {
cfg.DefaultQuota = DefaultQuota
}
cfg.Root = filepath.Clean(cfg.Root)
for _, d := range []string{cfg.Root, filepath.Join(cfg.Root, "users"), filepath.Join(cfg.Root, "public")} {
if err := os.MkdirAll(d, 0o755); err != nil {
return nil, err
}
}
return &Service{st: st, cfg: cfg, reg: newRegistry()}, nil
}
// privRoot is the absolute private-workspace directory for a member.
func (s *Service) privRoot(user string) string {
return filepath.Join(s.cfg.Root, "users", user)
}
// pubRoot is the absolute shared public-area directory.
func (s *Service) pubRoot() string { return filepath.Join(s.cfg.Root, "public") }
// ensureWorkspace creates a member's private workspace if absent.
func (s *Service) ensureWorkspace(user string) error {
return os.MkdirAll(s.privRoot(user), 0o700)
}
// quotaFor returns the effective quota (bytes) for a user: their per-user
// override if set, else the server default.
func (s *Service) quotaFor(userID int64) int64 {
if fa, err := s.st.FilesAccess(userID); err == nil && fa.QuotaBytes > 0 {
return fa.QuotaBytes
}
return s.cfg.DefaultQuota
}
// publicWritable reports whether members may write to the shared area. The
// persisted files_settings value wins; default is true (members-only write).
func (s *Service) publicWritable() bool {
if v, ok, err := s.st.FilesSetting(settingPublicWrite); err == nil && ok {
return v != "off"
}
return true
}
// SetPublicWrite toggles members' write access to the shared public area.
func (s *Service) SetPublicWrite(on bool) error {
v := "off"
if on {
v = "members"
}
return s.st.SetFilesSetting(settingPublicWrite, v)
}
// dirSize returns the total bytes used under root (regular files only; symlinks
// are not followed). A missing root counts as 0.
func dirSize(root string) (int64, error) {
var total int64
err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error {
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if d.Type().IsRegular() {
info, err := d.Info()
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
total += info.Size()
}
return nil
})
if os.IsNotExist(err) {
return 0, nil
}
return total, err
}
// Usage is a member's workspace usage snapshot.
type Usage struct {
Bytes int64
Quota int64
}
// Free reports remaining bytes (never negative).
func (u Usage) Free() int64 {
if u.Quota <= u.Bytes {
return 0
}
return u.Quota - u.Bytes
}
// Usage computes a member's private-workspace usage against their quota.
func (s *Service) Usage(u store.User) (Usage, error) {
used, err := dirSize(s.privRoot(u.Name))
if err != nil {
return Usage{}, err
}
return Usage{Bytes: used, Quota: s.quotaFor(u.ID)}, nil
}
// --- live session registry (for the management TUI's Sessions pane) ----------
// Conn is a snapshot of a live SFTP connection, as shown by the management TUI.
type Conn struct {
ID int64
User string
Key string // SSH key fingerprint
Remote string
Started time.Time
RX int64 // bytes received from the client (uploads)
TX int64 // bytes sent to the client (downloads)
}
// liveConn is the registry's mutable view of an active connection. Its atomic
// counters are updated by countingRWC; snapshots copy out plain Conn values.
type liveConn struct {
id int64
user string
key string
remote string
started time.Time
rxBytes atomic.Int64
txBytes atomic.Int64
closer func() error
}
type registry struct {
mu sync.Mutex
next int64
live map[int64]*liveConn
}
func newRegistry() *registry { return &registry{live: map[int64]*liveConn{}} }
func (r *registry) add(user, key, remote string, closer func() error) *liveConn {
r.mu.Lock()
defer r.mu.Unlock()
r.next++
c := &liveConn{id: r.next, user: user, key: key, remote: remote, started: time.Now(), closer: closer}
r.live[c.id] = c
return c
}
func (r *registry) remove(id int64) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.live, id)
}
func (r *registry) snapshot() []Conn {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]Conn, 0, len(r.live))
for _, c := range r.live {
out = append(out, Conn{
ID: c.id, User: c.user, Key: c.key, Remote: c.remote, Started: c.started,
RX: c.rxBytes.Load(), TX: c.txBytes.Load(),
})
}
return out
}
// Sessions returns a snapshot of live SFTP connections, newest first.
func (s *Service) Sessions() []Conn {
cs := s.reg.snapshot()
for i, j := 0, len(cs)-1; i < j; i, j = i+1, j-1 {
cs[i], cs[j] = cs[j], cs[i]
}
return cs
}
// --- operator / management surface ------------------------------------------
// Users lists accounts for the management TUI (newest first).
func (s *Service) Users() ([]store.User, error) { return s.st.ListUsers(10000) }
// Access returns a user's SFTP access record (quota override + revoked flag).
func (s *Service) Access(userID int64) (store.FilesAccess, error) {
return s.st.FilesAccess(userID)
}
// SetQuota sets a per-user quota override (bytes; 0 = server default).
func (s *Service) SetQuota(userID, bytes int64) error { return s.st.SetFilesQuota(userID, bytes) }
// SetRevoked revokes/restores a user's SFTP access (BBS login is unaffected).
func (s *Service) SetRevoked(userID int64, revoked bool) error {
return s.st.SetFilesRevoked(userID, revoked)
}
// PublicWritable reports whether members may currently write to the public area.
func (s *Service) PublicWritable() bool { return s.publicWritable() }
// PublicList lists the top level of the shared public area for moderation.
func (s *Service) PublicList() ([]Entry, error) {
des, err := os.ReadDir(s.pubRoot())
if err != nil {
return nil, err
}
out := make([]Entry, 0, len(des))
for _, de := range des {
fi, err := de.Info()
if err != nil {
continue
}
out = append(out, Entry{Name: de.Name(), IsDir: de.IsDir(), Size: fi.Size(), ModTime: fi.ModTime()})
}
return out, nil
}
// PublicRemove deletes a top-level entry from the public area (moderation). name
// is treated as a single segment; traversal is rejected.
func (s *Service) PublicRemove(name string) error {
base := filepath.Base(filepath.Clean("/" + name))
if base == "." || base == "/" || base == ".." {
return os.ErrInvalid
}
target := filepath.Join(s.pubRoot(), base)
if !within(s.pubRoot(), target) {
return os.ErrPermission
}
return os.RemoveAll(target)
}
// Kick force-disconnects a live SFTP connection by id. Returns false if unknown.
func (s *Service) Kick(id int64) bool {
s.reg.mu.Lock()
c, ok := s.reg.live[id]
s.reg.mu.Unlock()
if !ok {
return false
}
if c.closer != nil {
_ = c.closer()
}
return true
}

View file

@ -0,0 +1,87 @@
package files
import (
"io"
"net"
"sort"
"testing"
"github.com/pkg/sftp"
)
// startE2E wires a real sftp client to the request server for one member, over
// an in-memory pipe (no SSH transport).
func startE2E(t *testing.T) *sftp.Client {
t.Helper()
svc, _, u := newTestService(t)
sess, err := svc.newSession(u)
if err != nil {
t.Fatal(err)
}
srvConn, cliConn := net.Pipe()
handlers := sftp.Handlers{FileGet: sess, FilePut: sess, FileCmd: sess, FileList: sess}
server := sftp.NewRequestServer(srvConn, handlers)
go func() { _ = server.Serve() }()
client, err := sftp.NewClientPipe(cliConn, cliConn)
if err != nil {
t.Fatalf("NewClientPipe: %v", err)
}
t.Cleanup(func() { _ = client.Close(); _ = server.Close() })
return client
}
func TestE2E_RootListing(t *testing.T) {
client := startE2E(t)
infos, err := client.ReadDir("/")
if err != nil {
t.Fatalf("ReadDir /: %v", err)
}
var names []string
for _, fi := range infos {
names = append(names, fi.Name())
}
sort.Strings(names)
if len(names) != 2 || names[0] != "me" || names[1] != "public" {
t.Errorf("root listing = %v, want [me public]", names)
}
}
func TestE2E_UploadDownloadPrivate(t *testing.T) {
client := startE2E(t)
f, err := client.Create("/me/hello.txt")
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := f.Write([]byte("hello bbs")); err != nil {
t.Fatalf("Write: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
rf, err := client.Open("/me/hello.txt")
if err != nil {
t.Fatalf("Open: %v", err)
}
defer rf.Close()
got, err := io.ReadAll(rf)
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
if string(got) != "hello bbs" {
t.Errorf("read = %q, want %q", got, "hello bbs")
}
}
func TestE2E_TraversalDenied(t *testing.T) {
client := startE2E(t)
// A normalizing client may collapse "/me/../.." to "/" before sending, so
// the guarantee we assert is the negative one: no escape ever yields a
// handle onto a system path.
if _, err := client.Open("/me/../../../../etc/passwd"); err == nil {
t.Error("opening an escaping path should fail")
}
if _, err := client.Open("/etc/passwd"); err == nil {
t.Error("opening an unknown area should fail")
}
}

View file

@ -0,0 +1,187 @@
package files
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/pkg/sftp"
"github.com/profullstack/agentbbs/internal/store"
)
func newTestService(t *testing.T) (*Service, store.Store, store.User) {
t.Helper()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
svc, err := New(st, Config{Root: filepath.Join(dir, "files"), DefaultQuota: 1 << 20})
if err != nil {
t.Fatalf("New: %v", err)
}
u, err := st.EnsureUser("alice", "member", "SHA256:alicekey")
if err != nil {
t.Fatalf("EnsureUser: %v", err)
}
return svc, st, u
}
func TestResolveConfinement(t *testing.T) {
svc, _, u := newTestService(t)
sess, err := svc.newSession(u)
if err != nil {
t.Fatal(err)
}
priv := svc.privRoot(u.Name)
pub := svc.pubRoot()
// These must never resolve to a path outside their area root. Some are
// expected to error outright; for the rest, assert containment.
escapes := []string{
"/me/../../../etc/passwd",
"/me/../../etc",
"/me/sub/../../../../etc/shadow",
"/public/../me/secret",
"/public/../../etc",
"/../etc/passwd",
"/me/./../../public/../../root",
}
for _, p := range escapes {
res, err := sess.resolve(p)
if err != nil {
continue // rejected outright — fine
}
if res.root {
continue // collapsed to the synthetic root — fine
}
ok := within(priv, res.real) || within(pub, res.real)
if !ok {
t.Errorf("resolve(%q) escaped: %q (priv=%q pub=%q)", p, res.real, priv, pub)
}
}
}
func TestResolveAreas(t *testing.T) {
svc, _, u := newTestService(t)
sess, _ := svc.newSession(u)
root, _ := sess.resolve("/")
if !root.root {
t.Error("/ should be the synthetic root")
}
me, err := sess.resolve("/me/notes.txt")
if err != nil || me.area != areaMe || !me.writable {
t.Errorf("/me should be writable me-area: %+v err=%v", me, err)
}
if !within(svc.privRoot(u.Name), me.real) {
t.Errorf("/me path %q not under priv root", me.real)
}
pub, err := sess.resolve("/public/shared.txt")
if err != nil || pub.area != areaPublic {
t.Errorf("/public should be public area: %+v err=%v", pub, err)
}
if _, err := sess.resolve("/etc/passwd"); err == nil {
t.Error("unknown top-level area should be rejected")
}
}
func TestSymlinkEscapeBlocked(t *testing.T) {
svc, _, u := newTestService(t)
sess, _ := svc.newSession(u)
priv := svc.privRoot(u.Name)
// Plant a symlink inside the workspace pointing at the system root.
link := filepath.Join(priv, "escape")
if err := os.Symlink("/etc", link); err != nil {
t.Skipf("symlink unsupported: %v", err)
}
if _, err := sess.resolve("/me/escape/passwd"); err == nil {
t.Error("path through an escaping symlink must be rejected")
}
}
func TestPublicWriteACL(t *testing.T) {
svc, st, u := newTestService(t)
// Default: members may write to the public area.
sess, _ := svc.newSession(u)
r := sftp.NewRequest("Mkdir", "/public/uploads")
if err := sess.Filecmd(r); err != nil {
t.Fatalf("public mkdir should succeed by default: %v", err)
}
// Turn public write off → writes denied, reads still fine.
if err := svc.SetPublicWrite(false); err != nil {
t.Fatal(err)
}
_ = st
sess2, _ := svc.newSession(u)
if err := sess2.Filecmd(sftp.NewRequest("Mkdir", "/public/more")); err != sftp.ErrSSHFxPermissionDenied {
t.Errorf("public write should be denied when off, got %v", err)
}
res, err := sess2.resolve("/public/uploads")
if err != nil || res.writable {
t.Errorf("public should resolve read-only when write is off: %+v err=%v", res, err)
}
}
func TestQuotaEnforced(t *testing.T) {
svc, _, u := newTestService(t)
sess, _ := svc.newSession(u)
sess.quota = 100 // tiny
f, err := os.Create(filepath.Join(svc.privRoot(u.Name), "big"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
w := &quotaWriter{f: f, sess: sess}
if _, err := w.WriteAt(make([]byte, 80), 0); err != nil {
t.Fatalf("write within quota failed: %v", err)
}
if _, err := w.WriteAt(make([]byte, 80), 80); err != sftp.ErrSSHFxFailure {
t.Errorf("write over quota should fail, got %v", err)
}
}
func TestUsage(t *testing.T) {
svc, _, u := newTestService(t)
if err := svc.ensureWorkspace(u.Name); err != nil {
t.Fatal(err)
}
want := []byte(strings.Repeat("x", 512))
if err := os.WriteFile(filepath.Join(svc.privRoot(u.Name), "a.txt"), want, 0o644); err != nil {
t.Fatal(err)
}
usage, err := svc.Usage(u)
if err != nil {
t.Fatal(err)
}
if usage.Bytes != 512 {
t.Errorf("usage = %d, want 512", usage.Bytes)
}
if usage.Quota != 1<<20 {
t.Errorf("quota = %d, want default", usage.Quota)
}
}
func TestRevokeBlocksAndQuotaOverride(t *testing.T) {
svc, st, u := newTestService(t)
if err := st.SetFilesQuota(u.ID, 4096); err != nil {
t.Fatal(err)
}
if got := svc.quotaFor(u.ID); got != 4096 {
t.Errorf("quotaFor = %d, want 4096 override", got)
}
if err := st.SetFilesRevoked(u.ID, true); err != nil {
t.Fatal(err)
}
fa, err := st.FilesAccess(u.ID)
if err != nil || !fa.Revoked {
t.Errorf("FilesAccess revoked = %+v err=%v", fa, err)
}
}

470
internal/files/fs.go Normal file
View file

@ -0,0 +1,470 @@
package files
import (
"errors"
"io"
"os"
"path"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/pkg/sftp"
"github.com/profullstack/agentbbs/internal/store"
)
// area names exposed at the virtual root.
const (
areaMe = "me"
areaPublic = "public"
)
// errEscape is returned when a resolved path would leave its area root. It maps
// to an SFTP permission-denied; it must never reach the client as a real path.
var errEscape = errors.New("files: path escapes its area")
// session is one member's view of the filesystem for the life of an SFTP
// connection. It implements the pkg/sftp request handlers and enforces area
// confinement, the public-area ACL, and the per-user quota.
type session struct {
svc *Service
user store.User
pubWrite bool
quota int64
used atomic.Int64 // live private-workspace usage, for quota checks
}
func (s *Service) newSession(u store.User) (*session, error) {
if err := s.ensureWorkspace(u.Name); err != nil {
return nil, err
}
used, err := dirSize(s.privRoot(u.Name))
if err != nil {
return nil, err
}
sess := &session{svc: s, user: u, pubWrite: s.publicWritable(), quota: s.quotaFor(u.ID)}
sess.used.Store(used)
return sess, nil
}
// resolved is the outcome of mapping a client (virtual) path to disk.
type resolved struct {
real string // absolute on-disk path ("" for the synthetic root)
area string // "", areaMe, or areaPublic
root bool // the synthetic "/" listing me + public
writable bool // whether this area accepts writes for this member
}
// resolve maps a client path to disk, confined to its area root. It rejects any
// path that escapes (lexically or via an existing symlink). This is the single
// security chokepoint — every handler goes through it.
func (s *session) resolve(p string) (resolved, error) {
clean := path.Clean("/" + strings.TrimSpace(p))
if clean == "/" || clean == "." {
return resolved{root: true}, nil
}
seg := strings.SplitN(strings.TrimPrefix(clean, "/"), "/", 2)
rest := ""
if len(seg) == 2 {
rest = seg[1]
}
var areaRoot, area string
writable := false
switch seg[0] {
case areaMe:
areaRoot, area, writable = s.svc.privRoot(s.user.Name), areaMe, true
case areaPublic:
areaRoot, area, writable = s.svc.pubRoot(), areaPublic, s.pubWrite
default:
return resolved{}, os.ErrNotExist
}
real, err := safeJoin(areaRoot, rest)
if err != nil {
return resolved{}, err
}
return resolved{real: real, area: area, writable: writable}, nil
}
// safeJoin joins rel onto root and verifies the result stays within root, both
// lexically and after resolving any symlinks that already exist on the path.
func safeJoin(root, rel string) (string, error) {
full := filepath.Join(root, filepath.FromSlash(rel))
full = filepath.Clean(full)
if !within(root, full) {
return "", errEscape
}
// Symlink guard: resolve the longest existing prefix and re-check. This
// catches a symlink (created out-of-band) that points outside the area.
probe := full
for {
if resolvedPath, err := filepath.EvalSymlinks(probe); err == nil {
if !within(root, resolvedPath) {
return "", errEscape
}
break
}
parent := filepath.Dir(probe)
if parent == probe {
break
}
probe = parent
}
return full, nil
}
// within reports whether p is root itself or lives under it.
func within(root, p string) bool {
if p == root {
return true
}
return strings.HasPrefix(p, root+string(os.PathSeparator))
}
// OpenFor builds a member session by account name, for the in-BBS browser. It
// returns the resolved store user too.
func (s *Service) OpenFor(name string) (*session, store.User, error) {
u, ok, err := s.st.UserByName(name)
if err != nil {
return nil, store.User{}, err
}
if !ok {
return nil, store.User{}, os.ErrNotExist
}
sess, err := s.newSession(u)
return sess, u, err
}
// Entry is a directory entry for the in-BBS browser.
type Entry struct {
Name string
IsDir bool
Size int64
ModTime time.Time
}
// entries lists a virtual directory for the browser, with the synthetic root
// showing the two areas. Directories sort before files, then by name.
func (s *session) entries(vpath string) ([]Entry, error) {
res, err := s.resolve(vpath)
if err != nil {
return nil, err
}
if res.root {
return []Entry{{Name: areaMe, IsDir: true}, {Name: areaPublic, IsDir: true}}, nil
}
des, err := os.ReadDir(res.real)
if err != nil {
return nil, err
}
out := make([]Entry, 0, len(des))
for _, de := range des {
fi, err := de.Info()
if err != nil {
continue
}
out = append(out, Entry{Name: de.Name(), IsDir: de.IsDir(), Size: fi.Size(), ModTime: fi.ModTime()})
}
sort.Slice(out, func(i, j int) bool {
if out[i].IsDir != out[j].IsDir {
return out[i].IsDir
}
return out[i].Name < out[j].Name
})
return out, nil
}
// canWrite reports whether the area containing vpath accepts writes.
func (s *session) canWrite(vpath string) bool {
res, err := s.resolve(vpath)
return err == nil && !res.root && res.writable
}
func (s *session) mkdir(vpath string) error {
res, err := s.resolve(vpath)
if err != nil {
return err
}
if res.root || !res.writable {
return os.ErrPermission
}
return os.Mkdir(res.real, 0o755)
}
// remove deletes a file or directory (recursively) within a writable area.
func (s *session) remove(vpath string) error {
res, err := s.resolve(vpath)
if err != nil {
return err
}
if res.root || !res.writable {
return os.ErrPermission
}
return os.RemoveAll(res.real)
}
// rename moves within a single writable area (no cross-area moves).
func (s *session) rename(oldv, newv string) error {
src, err := s.resolve(oldv)
if err != nil {
return err
}
dst, err := s.resolve(newv)
if err != nil {
return err
}
if src.root || dst.root || !src.writable || !dst.writable || src.area != dst.area {
return os.ErrPermission
}
return os.Rename(src.real, dst.real)
}
// readFile returns up to max bytes of a file for in-TUI viewing; truncated is
// true if the file was longer.
func (s *session) readFile(vpath string, max int64) (data []byte, truncated bool, err error) {
res, err := s.resolve(vpath)
if err != nil {
return nil, false, err
}
if res.root {
return nil, false, os.ErrInvalid
}
f, err := os.Open(res.real)
if err != nil {
return nil, false, err
}
defer f.Close()
buf := make([]byte, max+1)
n, err := io.ReadFull(f, buf)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
return nil, false, err
}
if int64(n) > max {
return buf[:max], true, nil
}
return buf[:n], false, nil
}
// --- pkg/sftp request handlers ----------------------------------------------
// Fileread serves downloads.
func (s *session) Fileread(r *sftp.Request) (io.ReaderAt, error) {
res, err := s.resolve(r.Filepath)
if err != nil {
return nil, sftpErr(err)
}
if res.root {
return nil, sftp.ErrSSHFxOpUnsupported
}
f, err := os.Open(res.real)
if err != nil {
return nil, sftpErr(err)
}
return f, nil
}
// Filewrite serves uploads, enforcing the per-user quota in the private area.
func (s *session) Filewrite(r *sftp.Request) (io.WriterAt, error) {
res, err := s.resolve(r.Filepath)
if err != nil {
return nil, sftpErr(err)
}
if res.root || !res.writable {
return nil, sftp.ErrSSHFxPermissionDenied
}
var startSize int64
if fi, err := os.Stat(res.real); err == nil {
startSize = fi.Size()
}
f, err := os.OpenFile(res.real, fileFlags(r.Pflags()), 0o644)
if err != nil {
return nil, sftpErr(err)
}
// The public area is operator-managed (no per-user quota); the private
// workspace is metered.
if res.area != areaMe {
return f, nil
}
return &quotaWriter{f: f, sess: s, tracked: startSize}, nil
}
// Filecmd handles mutating operations other than read/write.
func (s *session) Filecmd(r *sftp.Request) error {
res, err := s.resolve(r.Filepath)
if err != nil {
return sftpErr(err)
}
if res.root || !res.writable {
return sftp.ErrSSHFxPermissionDenied
}
switch r.Method {
case "Mkdir":
return sftpErr(os.Mkdir(res.real, 0o755))
case "Rmdir":
return sftpErr(os.Remove(res.real))
case "Remove":
return sftpErr(os.Remove(res.real))
case "Setstat":
return s.setstat(res.real, r)
case "Rename":
dst, err := s.resolve(r.Target)
if err != nil {
return sftpErr(err)
}
if dst.root || !dst.writable || dst.area != res.area {
// Renames stay within one writable area — no cross-area (and so no
// workspace-to-workspace) moves.
return sftp.ErrSSHFxPermissionDenied
}
return sftpErr(os.Rename(res.real, dst.real))
case "Symlink", "Link":
// No symlinks/hardlinks: they are an escape vector and have no place in
// a metered virtual workspace.
return sftp.ErrSSHFxOpUnsupported
default:
return sftp.ErrSSHFxOpUnsupported
}
}
func (s *session) setstat(real string, r *sftp.Request) error {
a := r.Attributes()
if a.Size != 0 || r.AttrFlags().Size {
if err := os.Truncate(real, int64(a.Size)); err != nil {
return sftpErr(err)
}
}
if r.AttrFlags().Permissions {
if err := os.Chmod(real, a.FileMode().Perm()); err != nil {
return sftpErr(err)
}
}
return nil
}
// Filelist handles List (readdir), Stat, and Readlink.
func (s *session) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
res, err := s.resolve(r.Filepath)
if err != nil {
return nil, sftpErr(err)
}
switch r.Method {
case "List":
if res.root {
return listerAt{dirInfo(areaMe), dirInfo(areaPublic)}, nil
}
entries, err := os.ReadDir(res.real)
if err != nil {
return nil, sftpErr(err)
}
infos := make([]os.FileInfo, 0, len(entries))
for _, e := range entries {
fi, err := e.Info()
if err != nil {
continue
}
infos = append(infos, fi)
}
return listerAt(infos), nil
case "Stat":
if res.root {
return listerAt{dirInfo("/")}, nil
}
fi, err := os.Stat(res.real)
if err != nil {
return nil, sftpErr(err)
}
return listerAt{fi}, nil
case "Readlink":
return nil, sftp.ErrSSHFxOpUnsupported
default:
return nil, sftp.ErrSSHFxOpUnsupported
}
}
// --- helpers ----------------------------------------------------------------
// quotaWriter wraps an *os.File and fails writes that would push the member's
// private workspace over quota. It is conservative: it counts the high-water
// mark of each file and never decrements live usage (recomputed next session).
type quotaWriter struct {
f *os.File
sess *session
tracked int64 // current accounted size of this file
}
func (w *quotaWriter) WriteAt(p []byte, off int64) (int, error) {
end := off + int64(len(p))
if end > w.tracked {
delta := end - w.tracked
if w.sess.used.Add(delta) > w.sess.quota {
w.sess.used.Add(-delta)
return 0, sftp.ErrSSHFxFailure // "quota exceeded"
}
w.tracked = end
}
return w.f.WriteAt(p, off)
}
func (w *quotaWriter) Close() error { return w.f.Close() }
// fileFlags maps SFTP open flags to os.OpenFile flags for writes.
func fileFlags(f sftp.FileOpenFlags) int {
flags := os.O_WRONLY
if f.Creat {
flags |= os.O_CREATE
}
if f.Trunc {
flags |= os.O_TRUNC
}
if f.Append {
flags |= os.O_APPEND
}
if f.Excl {
flags |= os.O_EXCL
}
return flags
}
// sftpErr translates an OS/internal error into the SFTP status a client should
// see, without leaking real paths. nil passes through.
func sftpErr(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, errEscape):
return sftp.ErrSSHFxPermissionDenied
case errors.Is(err, os.ErrNotExist):
return sftp.ErrSSHFxNoSuchFile
case errors.Is(err, os.ErrPermission):
return sftp.ErrSSHFxPermissionDenied
case errors.Is(err, os.ErrExist):
return sftp.ErrSSHFxFailure
default:
return sftp.ErrSSHFxFailure
}
}
// listerAt adapts a slice of FileInfo to sftp.ListerAt.
type listerAt []os.FileInfo
func (l listerAt) ListAt(dst []os.FileInfo, off int64) (int, error) {
if off >= int64(len(l)) {
return 0, io.EOF
}
n := copy(dst, l[off:])
if int(off)+n >= len(l) {
return n, io.EOF
}
return n, nil
}
// dirInfo is a synthetic directory FileInfo for the virtual-root entries.
type dirInfo string
func (d dirInfo) Name() string { return string(d) }
func (dirInfo) Size() int64 { return 0 }
func (dirInfo) Mode() os.FileMode { return os.ModeDir | 0o555 }
func (dirInfo) ModTime() time.Time { return time.Time{} }
func (dirInfo) IsDir() bool { return true }
func (dirInfo) Sys() any { return nil }

94
internal/files/server.go Normal file
View file

@ -0,0 +1,94 @@
package files
import (
"io"
"github.com/charmbracelet/ssh"
"github.com/pkg/sftp"
"github.com/profullstack/agentbbs/internal/auth"
)
// Subsystem returns the wish/ssh "sftp" subsystem handler. Wire it with
//
// wish.WithSubsystem("sftp", svc.Subsystem())
//
// so members reach their files over the existing :22 listener. Identity is the
// connecting SSH key (the username is ignored); access is refused for non-members,
// banned accounts, and members whose SFTP access an operator has revoked.
func (s *Service) Subsystem() ssh.SubsystemHandler {
return func(sess ssh.Session) {
fp := auth.Fingerprint(sess.PublicKey())
if fp == "" {
io.WriteString(sess.Stderr(), "files: an SSH key is required (try: sftp -i ~/.ssh/id_ed25519 ...)\n")
_ = sess.Exit(1)
return
}
u, ok, err := s.st.UserByFingerprint(fp)
if err != nil {
io.WriteString(sess.Stderr(), "files: account lookup failed\n")
_ = sess.Exit(1)
return
}
if !ok {
io.WriteString(sess.Stderr(), "files: this key isn't a member — register first: ssh join@\n")
_ = sess.Exit(1)
return
}
if u.Banned {
io.WriteString(sess.Stderr(), "files: this account is suspended\n")
_ = sess.Exit(1)
return
}
if fa, err := s.st.FilesAccess(u.ID); err == nil && fa.Revoked {
io.WriteString(sess.Stderr(), "files: SFTP access has been revoked for this account\n")
_ = sess.Exit(1)
return
}
fsSess, err := s.newSession(u)
if err != nil {
io.WriteString(sess.Stderr(), "files: could not open your workspace\n")
_ = sess.Exit(1)
return
}
rw := &countingRWC{inner: sess}
conn := s.reg.add(u.Name, fp, sess.RemoteAddr().String(), rw.Close)
rw.conn = conn
defer s.reg.remove(conn.id)
handlers := sftp.Handlers{FileGet: fsSess, FilePut: fsSess, FileCmd: fsSess, FileList: fsSess}
srv := sftp.NewRequestServer(rw, handlers)
defer srv.Close()
if err := srv.Serve(); err != nil && err != io.EOF {
io.WriteString(sess.Stderr(), "files: session ended\n")
}
}
}
// countingRWC wraps the SSH channel to meter bytes for the management TUI and to
// expose Close for force-disconnect. Read = bytes from the client (uploads);
// Write = bytes to the client (downloads).
type countingRWC struct {
inner io.ReadWriteCloser
conn *liveConn
}
func (c *countingRWC) Read(p []byte) (int, error) {
n, err := c.inner.Read(p)
if c.conn != nil {
c.conn.rxBytes.Add(int64(n))
}
return n, err
}
func (c *countingRWC) Write(p []byte) (int, error) {
n, err := c.inner.Write(p)
if c.conn != nil {
c.conn.txBytes.Add(int64(n))
}
return n, err
}
func (c *countingRWC) Close() error { return c.inner.Close() }

358
internal/files/tui.go Normal file
View file

@ -0,0 +1,358 @@
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])
}