mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +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
22
README.md
22
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
78
docs/PRD.md
78
docs/PRD.md
|
|
@ -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
95
docs/files.md
Normal 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
2
go.mod
|
|
@ -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
4
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
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])
|
||||
}
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
7
setup.sh
7
setup.sh
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue