mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-10-01 19:43:49 +00:00
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:
parent
68899180a7
commit
6dc94bd784
18 changed files with 2223 additions and 27 deletions
339
internal/files/admin.go
Normal file
339
internal/files/admin.go
Normal 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
317
internal/files/backend.go
Normal 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 ®istry{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
|
||||
}
|
||||
87
internal/files/e2e_test.go
Normal file
87
internal/files/e2e_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
187
internal/files/files_test.go
Normal file
187
internal/files/files_test.go
Normal 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 := "aWriter{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
470
internal/files/fs.go
Normal 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 "aWriter{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
94
internal/files/server.go
Normal 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
358
internal/files/tui.go
Normal 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])
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue