mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
Add a third storage area, /site — each member's own public root, served
unauthenticated on the web at ~<name> alongside the shared /public.
Web file host (files.<host>) is no longer a login wall:
- GET / -> directory of members' ~user sites (+ sign-in link)
- GET /~<name>/... -> anon read-only browse + clean file URLs of /site
- GET /public/... -> anon read-only browse + clean file URLs of shared
area (fixes bare /public requiring login: the old
Caddy `handle_path /public/*` never matched /public)
Login is now optional and gates only private /me + writes. The anon
surface has no route into anyone's /me and safeJoin rejects traversal.
Usage gauge now sums the member-owned areas (/me + /site) instead of
/me alone; shared /public stays operator-managed and unmetered.
Caddy: route all of files.<host> to the Go manager. Docs + tests updated
(anon download/browse, traversal confinement, /site metering).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
398 lines
12 KiB
Go
398 lines
12 KiB
Go
// 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"
|
|
"sort"
|
|
"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, "sites"), 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") }
|
|
|
|
// siteRoot is the absolute per-user public ("site") directory for a member,
|
|
// served unauthenticated at ~<name> on the web file host.
|
|
func (s *Service) siteRoot(user string) string {
|
|
return filepath.Join(s.cfg.Root, "sites", user)
|
|
}
|
|
|
|
// ensureWorkspace creates a member's private workspace if absent.
|
|
func (s *Service) ensureWorkspace(user string) error {
|
|
return os.MkdirAll(s.privRoot(user), 0o700)
|
|
}
|
|
|
|
// ensureSite creates a member's public site directory if absent. It is
|
|
// world-readable (0o755) because the web host serves it anonymously at ~<name>.
|
|
func (s *Service) ensureSite(user string) error {
|
|
return os.MkdirAll(s.siteRoot(user), 0o755)
|
|
}
|
|
|
|
// ownedUsage sums the member-owned areas — their private /me workspace plus
|
|
// their public /site — for the quota gauge. The shared /public area is
|
|
// operator-managed and is not metered per user.
|
|
func (s *Service) ownedUsage(user string) (int64, error) {
|
|
priv, err := dirSize(s.privRoot(user))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
site, err := dirSize(s.siteRoot(user))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return priv + site, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SitePeer is a member with a published public site, for the anonymous ~user
|
|
// directory index on the web file host.
|
|
type SitePeer struct {
|
|
Name string
|
|
Bytes int64
|
|
}
|
|
|
|
// PublicSites lists members who have published anything to their public /site,
|
|
// sorted by name — the source for the anonymous ~user directory at the root of
|
|
// the web file host. Members with an empty site are omitted.
|
|
func (s *Service) PublicSites() ([]SitePeer, error) {
|
|
users, err := s.st.ListUsers(10000)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]SitePeer, 0, len(users))
|
|
for _, u := range users {
|
|
if u.Banned {
|
|
continue
|
|
}
|
|
n, err := dirSize(s.siteRoot(u.Name))
|
|
if err != nil || n == 0 {
|
|
continue
|
|
}
|
|
out = append(out, SitePeer{Name: u.Name, Bytes: n})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out, nil
|
|
}
|
|
|
|
// AnonRoot resolves the on-disk root for an anonymous, read-only browse target:
|
|
// the shared public area (name == "") or a member's public site (name = the
|
|
// ~handle). ok is false when the named member does not exist or is banned. The
|
|
// returned root is a confinement boundary — callers must safeJoin onto it.
|
|
func (s *Service) AnonRoot(name string) (root string, ok bool, err error) {
|
|
if name == "" {
|
|
return s.pubRoot(), true, nil
|
|
}
|
|
u, found, err := s.st.UserByName(name)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if !found || u.Banned {
|
|
return "", false, nil
|
|
}
|
|
return s.siteRoot(u.Name), true, nil
|
|
}
|
|
|
|
// SafeJoin exposes the area-confinement join (lexical + symlink-escape guard)
|
|
// for the web host's anonymous read-only surface.
|
|
func (s *Service) SafeJoin(root, rel string) (string, error) { return safeJoin(root, rel) }
|
|
|
|
// 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 owned-storage usage (private /me + public /site)
|
|
// against their quota.
|
|
func (s *Service) Usage(u store.User) (Usage, error) {
|
|
used, err := s.ownedUsage(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
|
|
}
|