Merge feat/files-store-provisioning: per-user /site + anonymous web file surface

# Conflicts:
#	setup.sh
This commit is contained in:
Anthony Ettinger 2026-06-26 00:53:38 +00:00
commit 192b117fc1
8 changed files with 515 additions and 43 deletions

View file

@ -31,26 +31,30 @@ gated on membership, *not* on the paid Founding Lifetime plan:
- Operators can revoke an individual account's SFTP access (abuse response) - Operators can revoke an individual account's SFTP access (abuse response)
without touching its BBS login — see the management TUI below. without touching its BBS login — see the management TUI below.
## Two areas ## Three areas
When you connect you see a virtual root with two directories: When you connect you see a virtual root with three directories:
| Path | What it is | Access | | Path | What it is | Access |
|---|---|---| |---|---|---|
| `/me` | Your **private** per-user workspace | read/write, quota-limited | | `/me` | Your **private** per-user workspace | read/write, quota-limited |
| `/site` | Your **own public area**, served on the web at `~<name>` | read/write (yours), world-read anonymously |
| `/public` | The single **shared public file area** (old-school BBS file area) | world-read; members-only write by default | | `/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 `/me` is private — there is **no** path from one member's `/me` to another's. The
surface is the one public area (PRD §9.3, amended). Both areas are confined: a two *public* surfaces are `/site` (per-member, the only thing exposed at
path that tries to escape its root (`../`, an absolute path, or a planted `~<name>`) and the single shared `/public`. All three areas are confined: a path
symlink) is rejected. that tries to escape its root (`../`, an absolute path, or a planted symlink) is
rejected, and the unauthenticated web surface (below) has no route into anyone's
`/me`.
## Quotas ## Quotas
Each private workspace has a byte quota (default **1 GiB**, set by Each member's **owned storage** has a byte quota (default **1 GiB**, set by
`AGENTBBS_FILES_QUOTA_MB`). Writes that would exceed it fail. Operators can set a `AGENTBBS_FILES_QUOTA_MB`). The gauge sums your private `/me` **and** your public
per-user override in the management TUI. The public area is operator-managed and `/site`; writes to either that would exceed the quota fail. Operators can set a
not metered per user. per-user override in the management TUI. The shared `/public` area is
operator-managed and **not** metered per user.
## In-BBS browser ## In-BBS browser
@ -86,7 +90,58 @@ content-blind.
|---|---|---| |---|---|---|
| `AGENTBBS_FILES` | `1` | enable the SFTP subsystem + Files plugin (`0` disables) | | `AGENTBBS_FILES` | `1` | enable the SFTP subsystem + Files plugin (`0` disables) |
| `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) | | `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) |
| `AGENTBBS_DATA` | `./data` | storage lives under `<data>/files/{users,public}` | | `AGENTBBS_DATA` | `./data` | storage lives under `<data>/files/{users,sites,public}` |
## Provisioning members from a public key (for external services)
Normally members onboard interactively (`ssh join@`). External services that
want to grant a user file storage without that flow — e.g. the TronBrowser
extension store letting a publisher upload bundles — can register an account
directly from an SSH **public** key (an account is just *handle + key
fingerprint*):
```bash
agentbbs provision-user --name acme --pubkey "ssh-ed25519 AAAA… acme@dev"
# or: --pubkey-file ./id_ed25519.pub
```
It normalizes the handle with the same rules as `join@` (`SanitizeUsername`),
fingerprints the key, and `EnsureUser`s the member; Files/SFTP access is then
available immediately (free for all members). Output is JSON (`{ok, name,
fingerprint, store_id}`); it refuses if the key already belongs to another
member or the handle is taken by a different key. The publisher can then:
```bash
scp dist.crx files@files.profullstack.com:/public/extensions/acme/
```
## The web file host (`files.<host>`)
The whole site is served by the Go file manager (`internal/files/web.go`), which
Caddy reverse-proxies. **Login is optional** — it gates only your private `/me`
and writes. The public surface is anonymous, read-only, and area-confined; it
has no route into anyone's `/me`.
| URL | Serves | Auth |
|---|---|---|
| `/` | A **directory of members' `~user` sites**, plus a sign-in link | none |
| `/~<name>[/path]` | That member's public `/site` — browse + download | none |
| `/public[/path]` | The shared public area — browse + download | none |
| `/?path=…`, `/upload`, … | The authenticated manager: your `/me`, `/site`, `/public` | webmail login |
Clean URLs map **1:1 to the SFTP paths**, so share links just work:
```
scp dist.crx files@files.profullstack.com:/public/extensions/acme/
-> https://files.profullstack.com/public/extensions/acme/dist.crx
scp index.html files@files.profullstack.com:/site/
-> https://files.profullstack.com/~chovy/index.html
```
Directories render a read-only browse listing; files stream with a content type
and a short (`max-age=300`) cache. Sign in (top-right link, webmail password) to
manage your own files.
## Provisioning members from a public key (for external services) ## Provisioning members from a public key (for external services)

View file

@ -26,6 +26,7 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -79,7 +80,7 @@ func New(st FilesStore, cfg Config) (*Service, error) {
cfg.DefaultQuota = DefaultQuota cfg.DefaultQuota = DefaultQuota
} }
cfg.Root = filepath.Clean(cfg.Root) cfg.Root = filepath.Clean(cfg.Root)
for _, d := range []string{cfg.Root, filepath.Join(cfg.Root, "users"), filepath.Join(cfg.Root, "public")} { 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 { if err := os.MkdirAll(d, 0o755); err != nil {
return nil, err return nil, err
} }
@ -95,11 +96,38 @@ func (s *Service) privRoot(user string) string {
// pubRoot is the absolute shared public-area directory. // pubRoot is the absolute shared public-area directory.
func (s *Service) pubRoot() string { return filepath.Join(s.cfg.Root, "public") } 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. // ensureWorkspace creates a member's private workspace if absent.
func (s *Service) ensureWorkspace(user string) error { func (s *Service) ensureWorkspace(user string) error {
return os.MkdirAll(s.privRoot(user), 0o700) 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 // quotaFor returns the effective quota (bytes) for a user: their per-user
// override if set, else the server default. // override if set, else the server default.
func (s *Service) quotaFor(userID int64) int64 { func (s *Service) quotaFor(userID int64) int64 {
@ -156,6 +184,58 @@ func dirSize(root string) (int64, error) {
return total, err 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. // Usage is a member's workspace usage snapshot.
type Usage struct { type Usage struct {
Bytes int64 Bytes int64
@ -170,9 +250,10 @@ func (u Usage) Free() int64 {
return u.Quota - u.Bytes return u.Quota - u.Bytes
} }
// Usage computes a member's private-workspace usage against their quota. // Usage computes a member's owned-storage usage (private /me + public /site)
// against their quota.
func (s *Service) Usage(u store.User) (Usage, error) { func (s *Service) Usage(u store.User) (Usage, error) {
used, err := dirSize(s.privRoot(u.Name)) used, err := s.ownedUsage(u.Name)
if err != nil { if err != nil {
return Usage{}, err return Usage{}, err
} }

View file

@ -41,8 +41,8 @@ func TestE2E_RootListing(t *testing.T) {
names = append(names, fi.Name()) names = append(names, fi.Name())
} }
sort.Strings(names) sort.Strings(names)
if len(names) != 2 || names[0] != "me" || names[1] != "public" { if len(names) != 3 || names[0] != "me" || names[1] != "public" || names[2] != "site" {
t.Errorf("root listing = %v, want [me public]", names) t.Errorf("root listing = %v, want [me public site]", names)
} }
} }

View file

@ -169,6 +169,34 @@ func TestUsage(t *testing.T) {
} }
} }
func TestUsageCountsSite(t *testing.T) {
svc, _, u := newTestService(t)
if err := svc.ensureWorkspace(u.Name); err != nil {
t.Fatal(err)
}
if err := svc.ensureSite(u.Name); err != nil {
t.Fatal(err)
}
// 512 bytes private (/me) + 256 bytes public site (/site) both count toward
// the member's owned-usage gauge; the shared /public area does not.
if err := os.WriteFile(filepath.Join(svc.privRoot(u.Name), "a.txt"), []byte(strings.Repeat("x", 512)), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(svc.siteRoot(u.Name), "b.txt"), []byte(strings.Repeat("y", 256)), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(svc.pubRoot(), "shared.txt"), []byte(strings.Repeat("z", 9999)), 0o644); err != nil {
t.Fatal(err)
}
usage, err := svc.Usage(u)
if err != nil {
t.Fatal(err)
}
if usage.Bytes != 768 {
t.Errorf("usage = %d, want 768 (512 /me + 256 /site, shared /public excluded)", usage.Bytes)
}
}
func TestRevokeBlocksAndQuotaOverride(t *testing.T) { func TestRevokeBlocksAndQuotaOverride(t *testing.T) {
svc, st, u := newTestService(t) svc, st, u := newTestService(t)
if err := st.SetFilesQuota(u.ID, 4096); err != nil { if err := st.SetFilesQuota(u.ID, 4096); err != nil {

View file

@ -17,10 +17,16 @@ import (
// area names exposed at the virtual root. // area names exposed at the virtual root.
const ( const (
areaMe = "me" areaMe = "me" // private, per-user workspace
areaPublic = "public" areaSite = "site" // the member's own public root (served at ~<name>)
areaPublic = "public" // the single shared public area
) )
// metered reports whether writes to an area count against the member's quota.
// The member's own areas (/me and /site) are metered; the shared /public is
// operator-managed and unmetered.
func metered(area string) bool { return area == areaMe || area == areaSite }
// errEscape is returned when a resolved path would leave its area root. It maps // 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. // to an SFTP permission-denied; it must never reach the client as a real path.
var errEscape = errors.New("files: path escapes its area") var errEscape = errors.New("files: path escapes its area")
@ -40,7 +46,10 @@ func (s *Service) newSession(u store.User) (*session, error) {
if err := s.ensureWorkspace(u.Name); err != nil { if err := s.ensureWorkspace(u.Name); err != nil {
return nil, err return nil, err
} }
used, err := dirSize(s.privRoot(u.Name)) if err := s.ensureSite(u.Name); err != nil {
return nil, err
}
used, err := s.ownedUsage(u.Name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -75,6 +84,10 @@ func (s *session) resolve(p string) (resolved, error) {
switch seg[0] { switch seg[0] {
case areaMe: case areaMe:
areaRoot, area, writable = s.svc.privRoot(s.user.Name), areaMe, true areaRoot, area, writable = s.svc.privRoot(s.user.Name), areaMe, true
case areaSite:
// The member's own public root: they read/write it, the world reads it
// anonymously at ~<name>.
areaRoot, area, writable = s.svc.siteRoot(s.user.Name), areaSite, true
case areaPublic: case areaPublic:
areaRoot, area, writable = s.svc.pubRoot(), areaPublic, s.pubWrite areaRoot, area, writable = s.svc.pubRoot(), areaPublic, s.pubWrite
default: default:
@ -152,7 +165,7 @@ func (s *session) entries(vpath string) ([]Entry, error) {
return nil, err return nil, err
} }
if res.root { if res.root {
return []Entry{{Name: areaMe, IsDir: true}, {Name: areaPublic, IsDir: true}}, nil return []Entry{{Name: areaMe, IsDir: true}, {Name: areaSite, IsDir: true}, {Name: areaPublic, IsDir: true}}, nil
} }
des, err := os.ReadDir(res.real) des, err := os.ReadDir(res.real)
if err != nil { if err != nil {
@ -296,8 +309,8 @@ func (s *session) webSave(vpath string, r io.Reader) (int64, error) {
if err != nil { if err != nil {
return 0, err return 0, err
} }
limit := int64(-1) // public area is operator-managed, unmetered limit := int64(-1) // shared public area is operator-managed, unmetered
if res.area == areaMe { if metered(res.area) {
if limit = s.quota - (s.used.Load() - existing); limit < 0 { if limit = s.quota - (s.used.Load() - existing); limit < 0 {
limit = 0 limit = 0
} }
@ -311,7 +324,7 @@ func (s *session) webSave(vpath string, r io.Reader) (int64, error) {
if cerr != nil { if cerr != nil {
return 0, cerr return 0, cerr
} }
if res.area == areaMe { if metered(res.area) {
s.used.Add(n - existing) s.used.Add(n - existing)
} }
return n, nil return n, nil
@ -373,9 +386,9 @@ func (s *session) Filewrite(r *sftp.Request) (io.WriterAt, error) {
if err != nil { if err != nil {
return nil, sftpErr(err) return nil, sftpErr(err)
} }
// The public area is operator-managed (no per-user quota); the private // The shared public area is operator-managed (no per-user quota); the
// workspace is metered. // member's own areas (/me and /site) are metered.
if res.area != areaMe { if !metered(res.area) {
return f, nil return f, nil
} }
return &quotaWriter{f: f, sess: s, tracked: startSize}, nil return &quotaWriter{f: f, sess: s, tracked: startSize}, nil
@ -443,7 +456,7 @@ func (s *session) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
switch r.Method { switch r.Method {
case "List": case "List":
if res.root { if res.root {
return listerAt{dirInfo(areaMe), dirInfo(areaPublic)}, nil return listerAt{dirInfo(areaMe), dirInfo(areaSite), dirInfo(areaPublic)}, nil
} }
entries, err := os.ReadDir(res.real) entries, err := os.ReadDir(res.real)
if err != nil { if err != nil {

View file

@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"os" "os"
"path" "path"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -57,13 +58,15 @@ func (s *Service) WebHandler(cfg WebConfig) http.Handler {
} }
h := &webSrv{svc: s, cfg: cfg, sess: map[string]webSession{}} h := &webSrv{svc: s, cfg: cfg, sess: map[string]webSession{}}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/", h.handleRoot) mux.HandleFunc("/", h.handleRoot) // index (~user dir) + /~name browsing + authed manager
mux.HandleFunc("/login", h.handleLogin) mux.HandleFunc("/login", h.handleLogin)
mux.HandleFunc("/logout", h.handleLogout) mux.HandleFunc("/logout", h.handleLogout)
mux.HandleFunc("/download", h.handleDownload) mux.HandleFunc("/download", h.handleDownload)
mux.HandleFunc("/upload", h.handleUpload) mux.HandleFunc("/upload", h.handleUpload)
mux.HandleFunc("/mkdir", h.handleMkdir) mux.HandleFunc("/mkdir", h.handleMkdir)
mux.HandleFunc("/delete", h.handleDelete) mux.HandleFunc("/delete", h.handleDelete)
mux.HandleFunc("/public", h.handleAnon) // shared public area (anon read-only)
mux.HandleFunc("/public/", h.handleAnon) // shared public area (anon read-only)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
return mux return mux
} }
@ -121,9 +124,16 @@ func randHex(n int) string {
// --- handlers --------------------------------------------------------------- // --- handlers ---------------------------------------------------------------
func (h *webSrv) handleRoot(w http.ResponseWriter, r *http.Request) { func (h *webSrv) handleRoot(w http.ResponseWriter, r *http.Request) {
// Anonymous per-member public browsing: /~<name>[/...]. No session required.
if strings.HasPrefix(r.URL.Path, "/~") {
h.handleAnon(w, r)
return
}
name, ok := h.lookup(r) name, ok := h.lookup(r)
if !ok { if !ok {
h.renderLogin(w, "") // Not signed in: the root is a public directory of members' ~user sites,
// with a sign-in link — not a login wall.
h.renderIndex(w, "")
return return
} }
vpath := cleanVPath(r.URL.Query().Get("path")) vpath := cleanVPath(r.URL.Query().Get("path"))
@ -171,9 +181,14 @@ func (h *webSrv) handleRoot(w http.ResponseWriter, r *http.Request) {
func (h *webSrv) handleLogin(w http.ResponseWriter, r *http.Request) { func (h *webSrv) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
// GET /login renders the sign-in form (the root is the public index).
if _, ok := h.lookup(r); ok {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }
h.renderLogin(w, "")
return
}
_ = r.ParseForm() _ = r.ParseForm()
user := strings.TrimSpace(r.FormValue("user")) user := strings.TrimSpace(r.FormValue("user"))
pass := r.FormValue("pass") pass := r.FormValue("pass")
@ -315,6 +330,128 @@ func (h *webSrv) renderLogin(w http.ResponseWriter, errMsg string) {
_ = loginTmpl.Execute(w, loginData{Title: h.cfg.Title, Err: errMsg}) _ = loginTmpl.Execute(w, loginData{Title: h.cfg.Title, Err: errMsg})
} }
// renderIndex serves the public landing page: a directory of members' ~user
// sites (each linking to their public /site), plus a link to the shared /public
// area and a sign-in link. No authentication required.
func (h *webSrv) renderIndex(w http.ResponseWriter, errMsg string) {
data := indexData{Title: h.cfg.Title, Err: errMsg}
if peers, err := h.svc.PublicSites(); err == nil {
for _, p := range peers {
data.Peers = append(data.Peers, indexPeer{Name: p.Name, URL: "/~" + p.Name + "/", UsedH: humanSize(p.Bytes)})
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = indexTmpl.Execute(w, data)
}
// handleAnon serves the unauthenticated, read-only public surface: the shared
// /public area and each member's public site at /~<name>. Directories render a
// browse listing; files stream with a content type and a short cache. It is
// confined to the area root by the same safeJoin guard as SFTP — there is no
// path to a member's private /me or above the area root.
func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
upath := path.Clean("/" + strings.TrimPrefix(r.URL.Path, "/"))
var name, rel, prefix, heading string
switch {
case upath == "/public" || strings.HasPrefix(upath, "/public/"):
name, rel, prefix, heading = "", strings.TrimPrefix(upath, "/public"), "/public", "/public"
case strings.HasPrefix(upath, "/~"):
seg := strings.SplitN(strings.TrimPrefix(upath, "/~"), "/", 2)
name = seg[0]
if name == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if len(seg) == 2 {
rel = "/" + seg[1]
}
prefix, heading = "/~"+name, "~"+name
default:
http.NotFound(w, r)
return
}
root, ok, err := h.svc.AnonRoot(name)
if err != nil || !ok {
http.NotFound(w, r)
return
}
real, err := h.svc.SafeJoin(root, strings.TrimPrefix(rel, "/"))
if err != nil {
http.NotFound(w, r) // escaped the area root
return
}
fi, err := os.Stat(real)
if err != nil {
http.NotFound(w, r)
return
}
if fi.IsDir() {
h.renderAnonDir(w, prefix, heading, rel, real)
return
}
f, err := os.Open(real)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
w.Header().Set("Cache-Control", "public, max-age=300")
http.ServeContent(w, r, fi.Name(), fi.ModTime(), f)
}
// renderAnonDir renders a read-only listing of a public directory. prefix is the
// area URL base ("/public" or "/~name"); rel is the path within it; real is the
// on-disk directory (already confined by handleAnon).
func (h *webSrv) renderAnonDir(w http.ResponseWriter, prefix, heading, rel, real string) {
des, err := os.ReadDir(real)
if err != nil {
http.Error(w, "cannot list files", http.StatusInternalServerError)
return
}
rel = path.Clean("/" + strings.TrimPrefix(rel, "/"))
data := anonData{Title: h.cfg.Title, CurPath: heading}
if rel != "/" {
data.CurPath = heading + rel
parent := path.Dir(rel)
up := prefix
if parent != "/" {
up = prefix + parent
}
data.UpURL, data.UpOK = up+"/", true
}
for _, de := range des {
fi, err := de.Info()
if err != nil {
continue
}
url := prefix + path.Join(rel, de.Name())
if de.IsDir() {
url += "/"
}
data.Entries = append(data.Entries, anonEntry{
Name: de.Name(), IsDir: de.IsDir(), SizeH: humanSize(fi.Size()),
URL: url, Mod: fi.ModTime().Format("2006-01-02 15:04"),
})
}
sortEntries(data.Entries)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = anonTmpl.Execute(w, data)
}
// sortEntries orders a listing directories-first, then by name.
func sortEntries(es []anonEntry) {
sort.Slice(es, func(i, j int) bool {
if es[i].IsDir != es[j].IsDir {
return es[i].IsDir
}
return es[i].Name < es[j].Name
})
}
// --- view models + templates ------------------------------------------------ // --- view models + templates ------------------------------------------------
type loginData struct { type loginData struct {
@ -416,7 +553,63 @@ var listTmpl = template.Must(template.New("list").Parse(`<!doctype html><html><h
</tr>{{end}} </tr>{{end}}
{{if not .Entries}}<tr><td colspan=4 class=muted>(empty)</td></tr>{{end}} {{if not .Entries}}<tr><td colspan=4 class=muted>(empty)</td></tr>{{end}}
</table> </table>
<p class=muted style="margin-top:20px">Also reachable over SFTP: <code>sftp files@files.profullstack.com</code> (with your SSH key).</p> <p class=muted style="margin-top:20px">Your <code>/site</code> files are public at <a href="/~{{.User}}/">~{{.User}}</a>. Also reachable over SFTP with your SSH key: <code>sftp files@{{.Title}}</code>.</p>
</div></body></html>`))
type indexPeer struct {
Name string
URL string
UsedH string
}
type indexData struct {
Title string
Peers []indexPeer
Err string
}
type anonEntry struct {
Name string
IsDir bool
SizeH string
URL string
Mod string
}
type anonData struct {
Title string
CurPath string
UpURL string
UpOK bool
Entries []anonEntry
}
var indexTmpl = template.Must(template.New("index").Parse(`<!doctype html><html><head><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1"><title>{{.Title}}</title><style>` + baseCSS + `</style></head>
<body><div class=wrap>
<div class=bar><h1>{{.Title}}</h1><div class=muted><a href="/public/">/public</a> · <a href="/login">Sign in</a></div></div>
{{if .Err}}<div class="flash err">{{.Err}}</div>{{end}}
<p class=muted>Public member sites. Each links to that member's shared <code>/site</code> files. <a href="/login">Sign in</a> to manage your own files (private <code>/me</code> + your <code>/site</code>).</p>
<table><tr><th>Member</th><th class=right>Size</th></tr>
{{range .Peers}}<tr><td>📂 <a href="{{.URL}}">~{{.Name}}</a></td><td class=right>{{.UsedH}}</td></tr>{{end}}
{{if not .Peers}}<tr><td colspan=2 class=muted>No public sites yet sign in and add files to <code>/site</code>.</td></tr>{{end}}
</table>
<p class=muted style="margin-top:20px">Publish over SFTP: <code>scp file files@{{.Title}}:/site/</code> appears at <code>~yourname</code>.</p>
</div></body></html>`))
var anonTmpl = template.Must(template.New("anon").Parse(`<!doctype html><html><head><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1"><title>{{.CurPath}} · {{.Title}}</title><style>` + baseCSS + `</style></head>
<body><div class=wrap>
<div class=bar><h1>{{.Title}}</h1><div class=muted><a href="/">all sites</a> · <a href="/login">Sign in</a></div></div>
<div class=crumbs><b>{{.CurPath}}</b></div>
<p class=muted>Read-only public area.</p>
<table><tr><th>Name</th><th class=right>Size</th><th>Modified</th></tr>
{{if .UpOK}}<tr><td><a href="{{.UpURL}}"> ..</a></td><td></td><td></td></tr>{{end}}
{{range .Entries}}<tr>
<td>{{if .IsDir}}📁 <a href="{{.URL}}">{{.Name}}/</a>{{else}}📄 <a href="{{.URL}}">{{.Name}}</a>{{end}}</td>
<td class=right>{{if not .IsDir}}{{.SizeH}}{{end}}</td><td class=muted>{{.Mod}}</td></tr>{{end}}
{{if not .Entries}}<tr><td colspan=3 class=muted>(empty)</td></tr>{{end}}
</table>
</div></body></html>`)) </div></body></html>`))
// --- path helpers ----------------------------------------------------------- // --- path helpers -----------------------------------------------------------

View file

@ -124,6 +124,108 @@ func TestWebRoundTrip(t *testing.T) {
} }
} }
// loginCookie logs alice in and returns her session cookie.
func loginCookie(t *testing.T, h http.Handler) *http.Cookie {
t.Helper()
form := url.Values{"user": {"alice"}, "pass": {"secret"}}
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
h.ServeHTTP(rr, req)
cs := rr.Result().Cookies()
if len(cs) == 0 {
t.Fatal("login did not set a cookie")
}
return cs[0]
}
// uploadTo uploads body to dir as the holder of cookie.
func uploadTo(t *testing.T, h http.Handler, cookie *http.Cookie, dir, name, body string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("dir", dir)
fw, _ := mw.CreateFormFile("file", name)
_, _ = fw.Write([]byte(body))
_ = mw.Close()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/upload", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
h.ServeHTTP(rr, req)
if rr.Code != http.StatusSeeOther {
t.Fatalf("upload to %s: want redirect, got %d (%s)", dir, rr.Code, rr.Body.String())
}
}
func TestWebAnonPublicSite(t *testing.T) {
h, _ := webTestHandler(t)
cookie := loginCookie(t, h)
// alice publishes to her own public /site and to the shared /public.
uploadTo(t, h, cookie, "/site", "hello.txt", "from alice site")
uploadTo(t, h, cookie, "/public", "shared.txt", "shared file")
// The unauthenticated root lists ~alice (a public-site directory), not a wall.
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
if body := rr.Body.String(); !strings.Contains(body, "~alice") {
t.Fatalf("index missing ~alice directory entry: %.300s", body)
}
// Anonymous (no cookie) can download a member's published site file.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/hello.txt", nil))
if got, _ := io.ReadAll(rr.Body); string(got) != "from alice site" {
t.Fatalf("~alice file: got %q", got)
}
// Anonymous can download from the shared public area via a clean URL.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/public/shared.txt", nil))
if got, _ := io.ReadAll(rr.Body); string(got) != "shared file" {
t.Fatalf("/public file: got %q", got)
}
// Anonymous directory browse renders a listing.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/", nil))
if body := rr.Body.String(); !strings.Contains(body, "hello.txt") {
t.Fatalf("~alice browse missing file: %.300s", body)
}
}
func TestWebAnonCannotEscape(t *testing.T) {
h, _ := webTestHandler(t)
cookie := loginCookie(t, h)
uploadTo(t, h, cookie, "/me", "secret.txt", "private")
// There is no anonymous route into /me, and traversal out of a public area
// must not reach the private workspace.
for _, p := range []string{
"/~alice/../../users/alice/secret.txt",
"/public/../users/alice/secret.txt",
"/~alice/..%2f..%2fusers%2falice%2fsecret.txt",
"/~ghost/anything", // unknown member
} {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
if body, _ := io.ReadAll(rr.Body); strings.Contains(string(body), "private") {
t.Fatalf("anon path %q leaked private content", p)
}
if rr.Code == http.StatusOK && strings.Contains(rr.Body.String(), "secret.txt") {
t.Fatalf("anon path %q exposed /me", p)
}
}
// And the authed download API still rejects /me without a session.
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/download?path=/me/secret.txt", nil))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("anon /me download: want 401, got %d", rr.Code)
}
}
func TestWebPublicReadOnlyByDefault(t *testing.T) { func TestWebPublicReadOnlyByDefault(t *testing.T) {
h, _ := webTestHandler(t) h, _ := webTestHandler(t)
// Log in. // Log in.

View file

@ -700,19 +700,19 @@ FILES_SITE="
${FILES_DOMAIN} { ${FILES_DOMAIN} {
encode zstd gzip encode zstd gzip
# Public file area — unauthenticated, read-only HTTP for the shared /public # Everything is served by the agentbbs file-manager on loopback. It serves,
# directory, so download links (e.g. extension .crx/.zip) work for everyone. # all from the same virtual storage as SFTP:
# Maps 1:1 to the SFTP path: a member who runs # / public directory of members' ~user sites (no auth)
# /~<name>[/...] a member's public /site — anon read-only browse + files
# /public[/...] the shared public area — anon read-only browse + files
# (signed in) the member's private /me, their /site, and /public
# Clean URLs map 1:1 to the SFTP paths, so share links just work:
# scp dist.crx files@${FILES_DOMAIN}:/public/extensions/acme/ # scp dist.crx files@${FILES_DOMAIN}:/public/extensions/acme/
# gets the URL https://${FILES_DOMAIN}/public/extensions/acme/dist.crx . # -> https://${FILES_DOMAIN}/public/extensions/acme/dist.crx
# Everything else falls through to the auth'd web file manager below. # scp index.html files@${FILES_DOMAIN}:/site/
handle_path /public/* { # -> https://${FILES_DOMAIN}/~<name>/index.html
root * ${DATA_DIR}/files/public # The anon surface has no route to a member's private /me (see internal/files
header Cache-Control \"public, max-age=300\" # web tests); it is structurally read-only and area-confined.
file_server
}
# Member web file manager (webmail-password login; /me + /public browsing).
reverse_proxy http://${FILES_WEB_ADDR} reverse_proxy http://${FILES_WEB_ADDR}
} }
" "