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

View file

@ -36,7 +36,7 @@ plugins around one shared account system; the full product plan is in
| M3 — AgentGames (`game@` + WebSocket; TTT/C4, ELO ladder, replays) | ✅ |
| IRC (`irc.bbs.profullstack.com` — Ergo network for humans + agents) | ✅ |
| News (`news.profullstack.com` — members-only Usenet/NNTP for humans + agents) | ✅ |
| M4 — Files (cl1.tech SFTP workspaces) | ⬜ |
| M4 — Files (SFTP: private workspaces + shared public area, mgmt TUI) | ✅ |
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ |
## Run it
@ -67,6 +67,8 @@ Configuration (env):
| `AGENTBBS_GAME_MOVE_TIMEOUT` | `15` | AgentGames per-move deadline (s) — see [docs/agentgames.md](docs/agentgames.md) |
| `AGENTBBS_GAME_QUEUE_WAIT` | `120` | how long a lone agent waits for an opponent (s) |
| `AGENTBBS_GAME_WS_ADDR` | `127.0.0.1:8090` | AgentGames WebSocket endpoint (loopback; Caddy proxies `/play`) |
| `AGENTBBS_FILES` | `1` | member SFTP storage subsystem + Files plugin (`0` disables) — see [docs/files.md](docs/files.md) |
| `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) |
Ops:
@ -143,6 +145,24 @@ news.profullstack.com:563 # implicit TLS; login = your BBS member name
Set `NEWS=0` to skip it. Needs a DNS record `news.profullstack.com A -> host`.
Full details: [`docs/news.md`](docs/news.md).
### Files (SFTP)
Every member gets file storage over **SFTP**, on the same `:22` listener and the
same SSH key they log in with (`internal/files`, a virtual Go SFTP server — no OS
users). Two areas: a **private, quota-limited** `/me` workspace and a single
**shared public file area** `/public` (old-school BBS file area; members-only
write by default). `scp` and `rsync` ride the same endpoint:
```bash
sftp files@bbs.profullstack.com # username is conventional; your key is your identity
scp file.pdf files@bbs.profullstack.com:/me/
```
There's also an in-hub **Files** browser and an operator management TUI
(`ssh sftp@bbs.profullstack.com`, operators only) for sessions, quotas, and
moderating the public area. Set `AGENTBBS_FILES=0` to disable. Full details:
[`docs/files.md`](docs/files.md).
## Architecture
- **Go + charmbracelet**`wish` SSH server, `bubbletea` TUIs, `lipgloss` styling.

View file

@ -13,6 +13,7 @@ import (
"github.com/profullstack/agentbbs/internal/admin"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/files"
"github.com/profullstack/agentbbs/internal/plugin"
)
@ -154,6 +155,32 @@ func (a *app) adminTeaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return m, []tea.ProgramOption{tea.WithAltScreen()}
}
// filesAdminTeaHandler launches the SFTP server management TUI. Like admin@ it
// is gated by the operator allowlist and re-checked here so a direct hit is
// safe. Members transfer files via the sftp subsystem, not this route.
func (a *app) filesAdminTeaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
if a.files == nil {
wish.Println(s, "the Files service is disabled (AGENTBBS_FILES=0).")
_ = s.Exit(1)
return nil, nil
}
fp := auth.Fingerprint(s.PublicKey())
var name string
if fp != "" {
if u, found, _ := a.st.UserByFingerprint(fp); found {
name = u.Name
}
}
if name == "" || !auth.IsAdmin(name) {
wish.Println(s, "the SFTP management console is restricted to operators.")
_ = s.Exit(1)
return nil, nil
}
sessID, _ := a.st.RecordSession(0, s.User(), remoteIP(s), "sftp-admin")
go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }()
return files.NewAdminModel(a.files), []tea.ProgramOption{tea.WithAltScreen()}
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {

View file

@ -55,6 +55,7 @@ import (
"github.com/profullstack/agentbbs/internal/brand"
"github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/chat"
"github.com/profullstack/agentbbs/internal/files"
"github.com/profullstack/agentbbs/internal/forgejo"
"github.com/profullstack/agentbbs/internal/forwardemail"
"github.com/profullstack/agentbbs/internal/games"
@ -102,6 +103,7 @@ type app struct {
fe forwardemail.Config // premium @bbs email provisioning
forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning
live *liveReg // in-memory live-session registry (admin console)
files *files.Service // SFTP file storage (nil when AGENTBBS_FILES=0)
gamesReg *games.Registry // AgentGames catalog
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
dataDir string
@ -173,6 +175,22 @@ func main() {
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second)
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), qryptinviteplugin.Plugin{}, about.Plugin{}}
// Files (SFTP): per-user workspaces + a shared public area, reached over the
// :22 listener via `sftp files@<host>` (docs/files.md). Disable with
// AGENTBBS_FILES=0. The in-BBS browser is a hub plugin; the operator
// management TUI is the sftp@ route.
if env("AGENTBBS_FILES", "1") == "1" {
fsvc, err := files.New(a.st, files.Config{
Root: filepath.Join(dataDir, "files"),
DefaultQuota: int64(envInt("AGENTBBS_FILES_QUOTA_MB", 1024)) << 20,
})
if err != nil {
log.Fatal("files", "err", err)
}
a.files = fsvc
a.registry = append(a.registry, files.NewPlugin(fsvc))
}
// Custom domains: maintain the symlink farm Caddy serves and answer its
// on-demand-TLS "ask" query so certs are only issued for mapped domains.
if sm, err := sites.NewManager(st, dataDir); err != nil {
@ -250,7 +268,7 @@ func main() {
}
addr := env("AGENTBBS_ADDR", ":2222")
srv, err := wish.NewServer(
opts := []ssh.Option{
wish.WithAddress(addr),
wish.WithHostKeyPath(filepath.Join(dataDir, "ssh", "host_ed25519")),
// Keys are always accepted at the transport layer; identity and
@ -264,7 +282,13 @@ func main() {
a.track(), // register every session for the admin console
logging.Middleware(),
),
)
}
// SFTP rides the same :22 listener as a subsystem; identity is the SSH key,
// so `sftp files@<host>` works with the member's login key.
if a.files != nil {
opts = append(opts, wish.WithSubsystem("sftp", a.files.Subsystem()))
}
srv, err := wish.NewServer(opts...)
if err != nil {
log.Fatal("server", "err", err)
}
@ -291,9 +315,11 @@ func main() {
func (a *app) router() wish.Middleware {
btMw := bm.Middleware(a.teaHandler)
adminMw := bm.Middleware(a.adminTeaHandler)
filesAdminMw := bm.Middleware(a.filesAdminTeaHandler)
return func(next ssh.Handler) ssh.Handler {
hubHandler := activeterm.Middleware()(btMw(next))
adminHandler := activeterm.Middleware()(adminMw(next))
filesAdminHandler := activeterm.Middleware()(filesAdminMw(next))
return func(s ssh.Session) {
user := strings.ToLower(s.User())
code, isVideo := calls.RouteCode(user)
@ -318,6 +344,8 @@ func (a *app) router() wish.Middleware {
a.handleNews(s)
case auth.IsMailName(user):
a.handleMail(s)
case auth.IsFilesAdminName(user):
filesAdminHandler(s)
case isVideo:
a.handleVideo(s, code)
case user == "agent":

View file

@ -30,7 +30,7 @@ supply inventory. The BBS hub is the funnel that builds that audience.
|---|---|
| `profullstack.com` | Primary BBS host — `ssh play@profullstack.com` (guest) and member access |
| `logicsrc.com` | Home of the AgentGames spec and developer/agent-facing docs |
| `cl1.tech` | Managed file-transfer service (SFTP product), surfaced in-BBS as a plugin |
| `bbs.profullstack.com` | Member file storage over SFTP — `sftp files@bbs.profullstack.com` (same SSH key as login) |
### 1.2 One-line pitch
@ -100,7 +100,7 @@ supply inventory. The BBS hub is the funnel that builds that audience.
▼ ▼ ▼ ▼
┌─────────┐ ┌───────────┐ ┌────────────┐ ┌──────────┐
│ Arcade │ │ AgentGames│ │ Files │ │ AgentAd │
│ plugin │ │ plugin │ │ (cl1.tech) │ │ plugin │
│ plugin │ │ plugin │ │ (SFTP) │ │ plugin │
└────┬────┘ └─────┬─────┘ └─────┬──────┘ └────┬─────┘
└─────────── sandbox runner ─────────┘ │
│ │
@ -193,20 +193,46 @@ Same backend, inverted player: **AI agents connect and compete**.
- **Spec home:** the protocol and SDK live on `logicsrc.com` for agent
developers.
### 5.3 Files (cl1.tech)
### 5.3 Files (SFTP)
A managed file workspace, surfaced in-BBS and as a standalone SFTP product on
`cl1.tech`.
Member file storage for bbs.profullstack.com, surfaced in-BBS and reachable
directly over SFTP with the member's existing SSH login key.
- **Model:** strictly **private, per-user** storage. Each account is chrooted to
its own directory tree with a disk quota.
- **Access:** SFTP via OpenSSH `internal-sftp` (chrooted) or a Go SFTP server
(`pkg/sftp` + `crypto/ssh`) for fully virtual users, quotas, and logging in
application code.
- **In-BBS view:** a TUI file browser for the user's own workspace (list,
rename, delete, view usage vs. quota).
- **Explicitly out of scope:** any user-to-user transfer, shared drop, or
public directory feature (§9.3, NG1).
- **Model:** two areas per server:
1. **Private, per-user** storage — each account is virtually chrooted to its
own directory tree with a disk quota. The default and primary surface.
2. **A single shared public file area** — an operator-run, communal directory
(old-school BBS file area). World-readable; write access is a tunable
(members-only by default). Operator-moderated; not encrypted or blind.
- **Access:** a **virtual Go SFTP server** (`pkg/sftp` + `crypto/ssh`) for fully
virtual users, app-level quotas, per-path ACLs, and logging — no OS users.
Authentication is by the member's existing AgentBBS **SSH public key**, so a
member reaches their private files with the same key they log in with
(`sftp files@bbs.profullstack.com`). `scp`/`rsync -e ssh` work over the same
endpoint.
- **In-BBS view:** a TUI file browser for the user's own workspace and the
shared area (list, rename, delete, up/download path, view usage vs. quota).
- **Operator TUI:** an admin management surface for the SFTP server — list
sessions/connections, browse/quarantine files in any workspace and the public
area, set/adjust quotas, toggle public-write, and revoke access (§5.3.1).
- **Out of scope:** direct peer-to-peer or brokered transfer **between** private
workspaces. Sharing happens only through the single moderated public area
(§9.3, NG1 as amended).
#### 5.3.1 Management TUI
A `bubbletea` admin console (gated to operators/admins), reachable both as an
in-BBS admin route and standalone. Panes:
- **Sessions:** live SFTP connections (user, key fingerprint, bytes, idle time),
with force-disconnect.
- **Workspaces:** per-user usage vs. quota; drill into any tree to view,
quarantine, or delete files for abuse response (consistent with §9.2 — the
operator can act on its own systems).
- **Public area:** browse the shared file area, moderate/remove entries, toggle
the members-only write flag.
- **Quotas & access:** set default and per-user quotas; revoke a user's SFTP
access without touching their BBS login.
### 5.4 AgentAd (marketplace)
@ -306,12 +332,18 @@ The platform does **not** adopt content-blind/zero-knowledge storage designed so
the operator cannot inspect hosted files. Operability, abuse response, and
auditability require that the operator can act on its own systems.
### 9.3 No user-to-user distribution
### 9.3 No peer-to-peer distribution (amended)
File workspaces are private and per-user. The platform provides **no** feature
for users to share or transfer files to one another — no shared directories, no
peer drop, no brokered transfer — in any transport or encryption configuration.
This is a hard product boundary, not a tunable.
Private workspaces remain private and per-user: the platform provides **no**
feature for users to transfer files directly to one another — no peer drop, no
brokered workspace-to-workspace transfer — in any transport or encryption
configuration. That direct path is a hard product boundary, not a tunable.
Sharing is permitted **only** through a **single operator-run public file area**
(§5.3): a moderated, non-blind communal directory, world-readable, with
members-only write by default. Because it is operator-run and inspectable
(§9.2), the operator can moderate and act on takedown notices (§9.4). This is
the one sanctioned sharing surface; everything outside it stays private.
### 9.4 Standard hosting compliance
@ -333,7 +365,7 @@ from the start.
| **M1 — Arcade** | doom-ascii + Freedoom/shareware, sandbox runner, guest play, member saves & leaderboards |
| **M2 — Admin** | plugin enable/disable, user moderation, session audit |
| **M3 — AgentGames** | game protocol, phase-1 catalog, agent auth, ladders, replays; spec published on logicsrc.com |
| **M4 — Files (cl1.tech)** | private per-user SFTP workspaces, quotas, in-BBS browser |
| **M4 — Files (SFTP)** | virtual Go SFTP server (key auth), private per-user workspaces + quotas, single shared public area, in-BBS file browser, operator management TUI |
| **M5 — AgentAd** | two-sided marketplace, buyer storefront, seller dashboard, creative review, revenue share |
| **M6 — Hardening & scale** | rate limits, fail2ban, metrics, Postgres migration path, web buyer dashboard |
@ -345,8 +377,10 @@ from the start.
websocket/API endpoint? (Affects how non-interactive agents authenticate.)
2. **Sandbox technology:** Docker/Podman per session vs. `systemd-run` transient
scopes — which fits the target VPS footprint best?
3. **Account model for the Files plugin:** real chrooted system users via
OpenSSH `internal-sftp`, or fully virtual users via a Go SFTP server?
3. ~~**Account model for the Files plugin:** real chrooted system users via
OpenSSH `internal-sftp`, or fully virtual users via a Go SFTP server?~~
**Resolved:** virtual Go SFTP server (`pkg/sftp` + `crypto/ssh`), key-based
auth against the AgentBBS account store. (§5.3)
4. **AgentAd inventory mix:** which surfaces ship first (interstitials vs. hub
banners vs. sponsored ladder slots)?
5. **Web footprint:** does the AgentAd buyer dashboard warrant a web app in v1,

95
docs/files.md Normal file
View file

@ -0,0 +1,95 @@
# Files (SFTP) — member storage
AgentBBS gives every verified member file storage over **SFTP**, reachable with
the same SSH key they log in with. It rides the existing `:22` listener as an
SSH *subsystem*, so there is no new port and no separate account:
```bash
# interactive
sftp files@bbs.profullstack.com
# one-shot copies (same endpoint, same key)
scp report.pdf files@bbs.profullstack.com:/me/
rsync -avz ./site/ -e ssh files@bbs.profullstack.com:/me/site/
```
The username (`files`) is conventional and ignored — **identity is your SSH
key** (one key = one account, like the rest of the BBS). `scp`/`rsync` work
because they tunnel over the same SSH transport.
## Two areas
When you connect you see a virtual root with two directories:
| Path | What it is | Access |
|---|---|---|
| `/me` | Your **private** per-user workspace | read/write, quota-limited |
| `/public` | The single **shared public file area** (old-school BBS file area) | world-read; members-only write by default |
There is **no** path from one member's `/me` to another's — the only sharing
surface is the one public area (PRD §9.3, amended). Both areas are confined: a
path that tries to escape its root (`../`, an absolute path, or a planted
symlink) is rejected.
## Quotas
Each private workspace has a byte quota (default **1 GiB**, set by
`AGENTBBS_FILES_QUOTA_MB`). Writes that would exceed it fail. Operators can set a
per-user override in the management TUI. The public area is operator-managed and
not metered per user.
## In-BBS browser
Inside the hub, the **Files** entry opens a TUI browser for your workspace and
the public area: navigate, view text files, make directories, rename, and
delete, with a live usage gauge. Actual transfers happen over SFTP/scp/rsync (a
PTY can't move file bytes).
## Operator management TUI
Operators (the `$AGENTBBS_ADMINS` allowlist) reach the SFTP management console
with:
```bash
ssh sftp@bbs.profullstack.com # aliases: sftpadmin@, filesadmin@
```
Panes (Tab to switch):
- **Sessions** — live SFTP connections (user, remote, rx/tx, idle); `x`
force-disconnects.
- **Workspaces** — every member's usage vs. quota; `Q` sets a per-user quota,
`x` revokes/restores SFTP access (the BBS login is unaffected).
- **Public area**`t` toggles members' write access; `x` removes an entry
(moderation).
Per PRD §9.2 the operator can inspect and act on hosted files; storage is not
content-blind.
## Configuration
| Var | Default | Meaning |
|---|---|---|
| `AGENTBBS_FILES` | `1` | enable the SFTP subsystem + Files plugin (`0` disables) |
| `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) |
| `AGENTBBS_DATA` | `./data` | storage lives under `<data>/files/{users,public}` |
## Implementation
- `internal/files` — a fully virtual Go SFTP server (`github.com/pkg/sftp` +
`crypto/ssh`); no OS users.
- `backend.go` — service, layout, quota/usage, live-session registry, operator
surface.
- `fs.go` — per-session virtual filesystem: `resolve()` is the single security
chokepoint (area confinement + symlink-escape guard) and the pkg/sftp
request handlers.
- `server.go` — the `wish.WithSubsystem("sftp", …)` handler: key auth → member
session → request server, with byte metering and force-disconnect.
- `tui.go` — the in-BBS member browser plugin.
- `admin.go` — the operator management TUI.
- Storage: `files_access` (per-user quota override + revoked flag) and
`files_settings` (e.g. public-write mode) in the shared SQLite store.
Security is covered by `internal/files/*_test.go`: path-traversal/confinement,
symlink-escape rejection, the public-write ACL, quota enforcement, and an
end-to-end run against a real SFTP client.

2
go.mod
View file

@ -16,6 +16,7 @@ require (
github.com/livekit/protocol v1.46.0
github.com/livekit/server-sdk-go/v2 v2.16.6
github.com/pion/webrtc/v4 v4.2.15
github.com/pkg/sftp v1.13.10
golang.org/x/crypto v0.50.0
golang.org/x/term v0.42.0
modernc.org/sqlite v1.52.0
@ -62,6 +63,7 @@ require (
github.com/jxskiss/base62 v1.1.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
github.com/livekit/mediatransportutil v0.0.0-20260521165806-8004f10ad0c5 // indirect

4
go.sum
View file

@ -149,6 +149,8 @@ github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -243,6 +245,8 @@ github.com/pion/webrtc/v4 v4.2.15 h1:Ir/MauNFCfg+kgyBYPQLiGdVWFlzEcLxqtuzAkYkky0
github.com/pion/webrtc/v4 v4.2.15/go.mod h1:CPTcyLfIzC4scOkQ4UY4pj6WvbUGhcNLIpK28cP5h6M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

View file

@ -69,6 +69,11 @@ var NewsNames = map[string]bool{"news": true}
// an interactive TUI with a PTY, or a JSON bot mode with a command/no PTY.
var MailNames = map[string]bool{"mail": true}
// FilesAdminNames route an operator into the SFTP server management TUI. Members
// transfer files via the "sftp" subsystem (sftp files@<host>); this interactive
// route is the operator console and is gated by the admin allowlist.
var FilesAdminNames = map[string]bool{"sftp": true, "sftpadmin": true, "filesadmin": true}
// GameNames are usernames that route to AgentGames: the line-delimited-JSON
// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias.
var GameNames = map[string]bool{"game": true, "games": true}
@ -103,6 +108,10 @@ func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] }
// IsMailName reports whether the SSH username requests the AgentMail client.
func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] }
// IsFilesAdminName reports whether the SSH username requests the SFTP server
// management TUI (operator-gated).
func IsFilesAdminName(u string) bool { return FilesAdminNames[strings.ToLower(u)] }
// systemReserved are names that don't drive an SSH route but would still
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
// infra hostnames — so members may not claim them as account names.
@ -119,7 +128,8 @@ var systemReserved = map[string]bool{
func IsReservedName(name string) bool {
n := strings.ToLower(name)
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] || systemReserved[n] {
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] ||
MailNames[n] || FilesAdminNames[n] || systemReserved[n] {
return true
}
return strings.HasPrefix(n, "video-") // video-<code> call routes

View file

@ -85,3 +85,24 @@ func TestIsReservedName(t *testing.T) {
}
}
}
func TestIsFilesAdminName(t *testing.T) {
for _, name := range []string{"sftp", "SFTP", "sftpadmin", "filesadmin"} {
if !IsFilesAdminName(name) {
t.Errorf("IsFilesAdminName(%q) = false, want true", name)
}
}
for _, name := range []string{"files", "bbs", "anthony", ""} {
if IsFilesAdminName(name) {
t.Errorf("IsFilesAdminName(%q) = true, want false", name)
}
}
}
func TestFilesAdminNamesReserved(t *testing.T) {
for _, name := range []string{"sftp", "sftpadmin", "filesadmin", "mail"} {
if !IsReservedName(name) {
t.Errorf("IsReservedName(%q) = false, want true (route name)", name)
}
}
}

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])
}

View file

@ -182,6 +182,23 @@ type Store interface {
// per-group number, and returns the stored row (with its number).
InsertNewsArticle(a NewsArticle) (NewsArticle, error)
// Files (SFTP) — per-user workspaces + the shared public area (docs/files.md).
// FilesAccess returns a user's SFTP access record (per-user quota override
// and revoked flag). A user with no row reports the zero value
// (QuotaBytes 0 = use the server default, Revoked false).
FilesAccess(userID int64) (FilesAccess, error)
// SetFilesQuota sets a per-user quota override in bytes (0 clears the
// override, falling back to the server default). Idempotent upsert.
SetFilesQuota(userID, bytes int64) error
// SetFilesRevoked revokes (or restores) a user's SFTP access without
// touching their BBS login. Idempotent upsert.
SetFilesRevoked(userID int64, revoked bool) error
// FilesSetting reads a Files service setting (e.g. the public-write mode).
FilesSetting(key string) (string, bool, error)
// SetFilesSetting writes a Files service setting. Idempotent upsert.
SetFilesSetting(key, value string) error
Close() error
}
@ -213,6 +230,14 @@ type NewsArticle struct {
CreatedAt time.Time
}
// FilesAccess is a user's SFTP access record. QuotaBytes is a per-user override
// (0 means "use the server default"); Revoked blocks SFTP without affecting the
// BBS login.
type FilesAccess struct {
QuotaBytes int64
Revoked bool
}
// RatingRow is one ladder entry.
type RatingRow struct {
User string
@ -471,6 +496,16 @@ CREATE TABLE IF NOT EXISTS news_articles (
);
CREATE INDEX IF NOT EXISTS idx_news_articles_grp ON news_articles(grp, num);
CREATE INDEX IF NOT EXISTS idx_news_articles_msgid ON news_articles(msg_id);
CREATE TABLE IF NOT EXISTS files_access (
user_id INTEGER PRIMARY KEY,
quota_bytes INTEGER NOT NULL DEFAULT 0,
revoked INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS files_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
`
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
@ -929,4 +964,65 @@ func (s *sqliteStore) RecordQryptInvite(username, jti string, quota int) error {
return tx.Commit()
}
// --- Files (SFTP) ------------------------------------------------------------
func (s *sqliteStore) FilesAccess(userID int64) (FilesAccess, error) {
var fa FilesAccess
var revoked int
err := s.db.QueryRow(
`SELECT quota_bytes, revoked FROM files_access WHERE user_id = ?`, userID,
).Scan(&fa.QuotaBytes, &revoked)
if errors.Is(err, sql.ErrNoRows) {
return FilesAccess{}, nil
}
if err != nil {
return FilesAccess{}, err
}
fa.Revoked = revoked != 0
return fa, nil
}
func (s *sqliteStore) SetFilesQuota(userID, bytes int64) error {
_, err := s.db.Exec(`
INSERT INTO files_access (user_id, quota_bytes, updated_at)
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ON CONFLICT(user_id) DO UPDATE SET
quota_bytes = excluded.quota_bytes,
updated_at = excluded.updated_at`, userID, bytes)
return err
}
func (s *sqliteStore) SetFilesRevoked(userID int64, revoked bool) error {
r := 0
if revoked {
r = 1
}
_, err := s.db.Exec(`
INSERT INTO files_access (user_id, revoked, updated_at)
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ON CONFLICT(user_id) DO UPDATE SET
revoked = excluded.revoked,
updated_at = excluded.updated_at`, userID, r)
return err
}
func (s *sqliteStore) FilesSetting(key string) (string, bool, error) {
var v string
err := s.db.QueryRow(`SELECT value FROM files_settings WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return v, true, nil
}
func (s *sqliteStore) SetFilesSetting(key, value string) error {
_, err := s.db.Exec(`
INSERT INTO files_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
return err
}
func (s *sqliteStore) Close() error { return s.db.Close() }

View file

@ -306,6 +306,13 @@ AGENTBBS_NEWS_TLS_KEY=${DATA_DIR}/news-tls/privkey.pem
# AGENTBBS_NEWS_ADDR=127.0.0.1:1119
# AGENTBBS_NEWS_TLS_ADDR=:563
# AGENTBBS_NEWS_GROUPS=pfs.announce:Announcements,pfs.general:General,pfs.agents:Agents
# Files (SFTP): member storage on the same :22 listener (sftp files@${DOMAIN}).
# Private per-user workspace (/me, quota-limited) + a shared public area
# (/public). Storage lives under \${AGENTBBS_DATA}/files. Operators manage it via
# \`ssh sftp@${DOMAIN}\`. Set AGENTBBS_FILES=0 to disable. See docs/files.md.
# AGENTBBS_FILES=1
# AGENTBBS_FILES_QUOTA_MB=1024
ENV
chmod 0640 "$ENV_DIR/agentbbs.env"
fi