files: per-user public at ~name/public; home = member directory

Re-model the web file host as a file server (not a website host):

- Drop the misnamed /site area. A member's public files are now their
  /me/public subfolder (unix ~/public), served anonymously at
  ~<name>/public. The rest of /me stays private; only ~name/public is
  ever exposed. Bare /~name redirects to /~name/public/.
- The root / is now a directory of ALL members, each linked to their BBS
  site (https://<bbs-host>/~name via WebConfig.SiteBase) AND their public
  files here (~name/public). No longer hides empty members.
- Sites/homepages stay on the BBS — files.<host> only links to them.
- Usage gauge is just /me again (which includes /me/public).

setup.sh + docs updated; tests cover ~name/public browse/download, the
bare-~name redirect, empty-member empty-listing, /public-only exposure,
and traversal confinement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-26 01:21:00 +00:00
parent 87fb2b4a1f
commit a2d4817a8e
9 changed files with 203 additions and 166 deletions

View file

@ -23,7 +23,10 @@ func (a *app) startFilesWeb() {
}
addr := env("AGENTBBS_FILES_WEB_ADDR", "127.0.0.1:8092")
title := env("AGENTBBS_FILES_WEB_TITLE", "files."+strings.TrimPrefix(a.host, "bbs."))
h := a.files.WebHandler(files.WebConfig{Authenticate: a.filesWebAuth, Title: title})
// Member homepages ("sites") live on the BBS host; the ~user directory links
// each member to https://<bbs-host>/~name alongside their public files here.
siteBase := env("AGENTBBS_FILES_WEB_SITE_BASE", "https://"+a.host)
h := a.files.WebHandler(files.WebConfig{Authenticate: a.filesWebAuth, Title: title, SiteBase: siteBase})
srv := &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}
go func() {
log.Info("files web listening", "addr", addr)

View file

@ -31,28 +31,32 @@ gated on membership, *not* on the paid Founding Lifetime plan:
- Operators can revoke an individual account's SFTP access (abuse response)
without touching its BBS login — see the management TUI below.
## Three areas
## Two areas
When you connect you see a virtual root with three directories:
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 |
| `/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 |
`/me` is private — there is **no** path from one member's `/me` to another's. The
two *public* surfaces are `/site` (per-member, the only thing exposed at
`~<name>`) and the single shared `/public`. All three areas are confined: a path
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`.
Your `/me` home has one special subfolder: **`/me/public`**. Anything you put
there is published on the web at **`~<name>/public`** (the unix `~/public`
convention) — that, and only that, is the per-member public surface. The rest of
`/me` stays private; there is **no** path from one member's `/me` to another's.
Both areas are confined: a path that tries to escape its root (`../`, an absolute
path, or a planted symlink) is rejected, and the unauthenticated web surface
(below) only ever exposes `~name/public`, never the rest of a member's `/me`.
> files.\<host\> is a **file server, not a website host**. Member homepages
> ("sites") live on the BBS at `https://<host>/~<name>`; the file host's `~user`
> directory links out to them but does not serve them.
## Quotas
Each member's **owned storage** has a byte quota (default **1 GiB**, set by
`AGENTBBS_FILES_QUOTA_MB`). The gauge sums your private `/me` **and** your public
`/site`; writes to either that would exceed the quota fail. Operators can set a
Each private workspace has a byte quota (default **1 GiB**, set by
`AGENTBBS_FILES_QUOTA_MB`). The gauge measures all of `/me` — including your
`/me/public` folder — and writes that would exceed it fail. Operators can set a
per-user override in the management TUI. The shared `/public` area is
operator-managed and **not** metered per user.
@ -90,7 +94,7 @@ content-blind.
|---|---|---|
| `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,sites,public}` |
| `AGENTBBS_DATA` | `./data` | storage lives under `<data>/files/{users,public}` (a member's public files are `users/<name>/public`) |
## Provisioning members from a public key (for external services)
@ -124,10 +128,11 @@ 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 |
| `/` | A **directory of all members** — each linked to their BBS **site** and their **public files** | none |
| `/~<name>/public[/path]` | That member's public files (`/me/public`) — browse + download | none |
| `/~<name>` | Redirects to `/~<name>/public/` | none |
| `/public[/path]` | The shared public area — browse + download | none |
| `/?path=…`, `/upload`, … | The authenticated manager: your `/me`, `/site`, `/public` | webmail login |
| `/?path=…`, `/upload`, … | The authenticated manager: your `/me` + the shared `/public` | webmail login |
Clean URLs map **1:1 to the SFTP paths**, so share links just work:
@ -135,8 +140,8 @@ 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
scp index.html files@files.profullstack.com:/me/public/
-> https://files.profullstack.com/~chovy/public/index.html
```
Directories render a read-only browse listing; files stream with a content type

View file

@ -80,7 +80,7 @@ func New(st FilesStore, cfg Config) (*Service, error) {
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")} {
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
}
@ -96,10 +96,11 @@ func (s *Service) privRoot(user string) string {
// 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)
// publicHome is the member's own public file folder: the public/ subdirectory of
// their private home. It is exposed anonymously on the web at ~<name>/public
// (the unix ~/public convention) and metered as part of /me.
func (s *Service) publicHome(user string) string {
return filepath.Join(s.privRoot(user), "public")
}
// ensureWorkspace creates a member's private workspace if absent.
@ -107,25 +108,14 @@ 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
// ensurePublicHome creates a member's ~/public folder if absent (and the home
// above it). The home stays private (0o700); the public/ subdir is the only
// part the web host exposes anonymously, and the Go server reads it directly.
func (s *Service) ensurePublicHome(user string) error {
if err := s.ensureWorkspace(user); err != nil {
return err
}
site, err := dirSize(s.siteRoot(user))
if err != nil {
return 0, err
}
return priv + site, nil
return os.MkdirAll(s.publicHome(user), 0o755)
}
// quotaFor returns the effective quota (bytes) for a user: their per-user
@ -184,40 +174,39 @@ func dirSize(root string) (int64, error) {
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
// Member is a member listed in the anonymous ~user directory at the root of the
// web file host. PublicBytes is the size of their ~/public folder.
type Member struct {
Name string
PublicBytes 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) {
// Members lists every (non-banned) account, sorted by name — the source for the
// anonymous ~user directory at the root of the web file host. Every member is
// listed (it is a real directory), each with a link to their site and to their
// public files; PublicBytes shows how much they have published.
func (s *Service) Members() ([]Member, error) {
users, err := s.st.ListUsers(10000)
if err != nil {
return nil, err
}
out := make([]SitePeer, 0, len(users))
out := make([]Member, 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})
n, _ := dirSize(s.publicHome(u.Name))
out = append(out, Member{Name: u.Name, PublicBytes: 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.
// the shared public area (name == "") or a member's public files folder, i.e.
// ~/public (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, and it never exposes the rest of the member's private home.
func (s *Service) AnonRoot(name string) (root string, ok bool, err error) {
if name == "" {
return s.pubRoot(), true, nil
@ -229,13 +218,13 @@ func (s *Service) AnonRoot(name string) (root string, ok bool, err error) {
if !found || u.Banned {
return "", false, nil
}
// Materialize the (idempotent) site dir so ~name is browsable the moment the
// account exists — before the member's first SFTP/web session creates it.
// Without this, joining onto a missing root trips the escape guard.
if err := s.ensureSite(u.Name); err != nil {
// Materialize the (idempotent) ~/public folder so ~name/public is browsable
// the moment the account exists. Without this, joining onto a missing root
// trips the escape guard.
if err := s.ensurePublicHome(u.Name); err != nil {
return "", false, err
}
return s.siteRoot(u.Name), true, nil
return s.publicHome(u.Name), true, nil
}
// SafeJoin exposes the area-confinement join (lexical + symlink-escape guard)
@ -256,10 +245,10 @@ func (u Usage) Free() int64 {
return u.Quota - u.Bytes
}
// Usage computes a member's owned-storage usage (private /me + public /site)
// against their quota.
// Usage computes a member's private-workspace usage (all of /me, which includes
// their ~/public folder) against their quota.
func (s *Service) Usage(u store.User) (Usage, error) {
used, err := s.ownedUsage(u.Name)
used, err := dirSize(s.privRoot(u.Name))
if err != nil {
return Usage{}, err
}

View file

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

View file

@ -169,20 +169,18 @@ func TestUsage(t *testing.T) {
}
}
func TestUsageCountsSite(t *testing.T) {
func TestUsageCountsPublicHome(t *testing.T) {
svc, _, u := newTestService(t)
if err := svc.ensureWorkspace(u.Name); err != nil {
if err := svc.ensurePublicHome(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.
// A member's ~/public folder lives inside their private /me home, so both the
// top-level private file and the public file count toward the quota 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 {
if err := os.WriteFile(filepath.Join(svc.publicHome(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 {
@ -193,7 +191,7 @@ func TestUsageCountsSite(t *testing.T) {
t.Fatal(err)
}
if usage.Bytes != 768 {
t.Errorf("usage = %d, want 768 (512 /me + 256 /site, shared /public excluded)", usage.Bytes)
t.Errorf("usage = %d, want 768 (512 /me + 256 /me/public, shared /public excluded)", usage.Bytes)
}
}

View file

@ -16,17 +16,16 @@ import (
)
// area names exposed at the virtual root.
//
// A member's home is /me (private). Its public/ subdirectory is exposed
// anonymously on the web at ~<name>/public — the unix ~/public convention — so
// "publish a file" just means dropping it in /me/public. The single shared
// /public area is the old-school BBS file area.
const (
areaMe = "me" // private, per-user workspace
areaSite = "site" // the member's own public root (served at ~<name>)
areaPublic = "public" // the single shared public area
areaMe = "me"
areaPublic = "public"
)
// 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
// to an SFTP permission-denied; it must never reach the client as a real path.
var errEscape = errors.New("files: path escapes its area")
@ -43,13 +42,12 @@ type session struct {
}
func (s *Service) newSession(u store.User) (*session, error) {
if err := s.ensureWorkspace(u.Name); err != nil {
// Ensure both the private home and its public/ subfolder exist, so writing to
// /me/public (which surfaces at ~name/public) just works.
if err := s.ensurePublicHome(u.Name); err != nil {
return nil, err
}
if err := s.ensureSite(u.Name); err != nil {
return nil, err
}
used, err := s.ownedUsage(u.Name)
used, err := dirSize(s.privRoot(u.Name))
if err != nil {
return nil, err
}
@ -84,10 +82,6 @@ func (s *session) resolve(p string) (resolved, error) {
switch seg[0] {
case areaMe:
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:
areaRoot, area, writable = s.svc.pubRoot(), areaPublic, s.pubWrite
default:
@ -165,7 +159,7 @@ func (s *session) entries(vpath string) ([]Entry, error) {
return nil, err
}
if res.root {
return []Entry{{Name: areaMe, IsDir: true}, {Name: areaSite, IsDir: true}, {Name: areaPublic, IsDir: true}}, nil
return []Entry{{Name: areaMe, IsDir: true}, {Name: areaPublic, IsDir: true}}, nil
}
des, err := os.ReadDir(res.real)
if err != nil {
@ -309,8 +303,8 @@ func (s *session) webSave(vpath string, r io.Reader) (int64, error) {
if err != nil {
return 0, err
}
limit := int64(-1) // shared public area is operator-managed, unmetered
if metered(res.area) {
limit := int64(-1) // public area is operator-managed, unmetered
if res.area == areaMe {
if limit = s.quota - (s.used.Load() - existing); limit < 0 {
limit = 0
}
@ -324,7 +318,7 @@ func (s *session) webSave(vpath string, r io.Reader) (int64, error) {
if cerr != nil {
return 0, cerr
}
if metered(res.area) {
if res.area == areaMe {
s.used.Add(n - existing)
}
return n, nil
@ -386,9 +380,9 @@ func (s *session) Filewrite(r *sftp.Request) (io.WriterAt, error) {
if err != nil {
return nil, sftpErr(err)
}
// The shared public area is operator-managed (no per-user quota); the
// member's own areas (/me and /site) are metered.
if !metered(res.area) {
// The public area is operator-managed (no per-user quota); the private
// workspace is metered.
if res.area != areaMe {
return f, nil
}
return &quotaWriter{f: f, sess: s, tracked: startSize}, nil
@ -456,7 +450,7 @@ func (s *session) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
switch r.Method {
case "List":
if res.root {
return listerAt{dirInfo(areaMe), dirInfo(areaSite), dirInfo(areaPublic)}, nil
return listerAt{dirInfo(areaMe), dirInfo(areaPublic)}, nil
}
entries, err := os.ReadDir(res.real)
if err != nil {

View file

@ -27,6 +27,10 @@ type WebConfig struct {
Authenticate func(user, pass string) (store.User, bool, error)
// Title is shown in the page header (e.g. "files.profullstack.com").
Title string
// SiteBase is the base URL of the member homepages ("sites"), e.g.
// "https://bbs.profullstack.com". The ~user directory links each member to
// SiteBase + "/~" + name. Empty disables the site link.
SiteBase string
// SessionTTL defaults to 12h.
SessionTTL time.Duration
}
@ -330,14 +334,23 @@ func (h *webSrv) renderLogin(w http.ResponseWriter, errMsg string) {
_ = 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.
// renderIndex serves the public landing page: a directory of every member,
// each linked to their site (homepage on the BBS) and their public files folder
// (~name/public here), 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)})
if members, err := h.svc.Members(); err == nil {
for _, m := range members {
peer := indexPeer{
Name: m.Name,
FilesURL: "/~" + m.Name + "/public/",
UsedH: humanSize(m.PublicBytes),
}
if h.cfg.SiteBase != "" {
peer.SiteURL = strings.TrimRight(h.cfg.SiteBase, "/") + "/~" + m.Name
}
data.Peers = append(data.Peers, peer)
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -345,10 +358,10 @@ func (h *webSrv) renderIndex(w http.ResponseWriter, errMsg string) {
}
// 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.
// /public area and each member's public files at /~<name>/public. 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 — only a
// member's ~/public subfolder is exposed, never the rest of their private /me.
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)
@ -358,18 +371,32 @@ func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) {
var name, rel, prefix, heading string
switch {
case upath == "/public" || strings.HasPrefix(upath, "/public/"):
// The shared/global public area.
name, rel, prefix, heading = "", strings.TrimPrefix(upath, "/public"), "/public", "/public"
case strings.HasPrefix(upath, "/~"):
// A member's own public files: /~<name>/public[/...]. The bare ~name (or
// anything not under /public) is not a file surface — only ~name/public is
// exposed; the rest of the member's home stays private.
seg := strings.SplitN(strings.TrimPrefix(upath, "/~"), "/", 2)
name = seg[0]
if name == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
after := ""
if len(seg) == 2 {
rel = "/" + seg[1]
after = "/" + seg[1]
}
prefix, heading = "/~"+name, "~"+name
if after == "" || after == "/" {
http.Redirect(w, r, "/~"+name+"/public/", http.StatusSeeOther)
return
}
if after != "/public" && !strings.HasPrefix(after, "/public/") {
http.NotFound(w, r)
return
}
rel = strings.TrimPrefix(after, "/public")
prefix, heading = "/~"+name+"/public", "~"+name+"/public"
default:
http.NotFound(w, r)
return
@ -386,10 +413,9 @@ func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) {
}
fi, err := os.Stat(real)
if err != nil {
// A known member whose site dir hasn't been created yet (it is created
// lazily on their first files session) renders as an empty listing — not
// a 404 — so ~name is reachable as soon as the account exists. A missing
// sub-path still 404s.
// A known member with an empty/not-yet-created ~/public folder renders as
// an empty listing — not a 404 — so ~name/public is reachable the moment
// the account exists. A missing sub-path still 404s.
if os.IsNotExist(err) && path.Clean("/"+strings.TrimPrefix(rel, "/")) == "/" {
h.renderAnonDir(w, prefix, heading, rel, real)
return
@ -562,13 +588,14 @@ var listTmpl = template.Must(template.New("list").Parse(`<!doctype html><html><h
</tr>{{end}}
{{if not .Entries}}<tr><td colspan=4 class=muted>(empty)</td></tr>{{end}}
</table>
<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>
<p class=muted style="margin-top:20px">Files in <code>/me/public</code> are public at <a href="/~{{.User}}/public/">~{{.User}}/public</a>. 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
Name string
FilesURL string // /~name/public/
SiteURL string // https://bbs.../~name (empty if no SiteBase)
UsedH string
}
type indexData struct {
@ -596,22 +623,25 @@ type anonData struct {
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>
<div class=bar><h1>{{.Title}}</h1><div class=muted><a href="/public/">shared /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}}
<p class=muted>Member directory. Each member has a <b>site</b> (their homepage on the BBS) and a <b>public files</b> folder here. <a href="/login">Sign in</a> to manage your own files.</p>
<table><tr><th>Member</th><th>Site</th><th>Public files</th><th class=right>Size</th></tr>
{{range .Peers}}<tr><td>👤 {{.Name}}</td>
<td>{{if .SiteURL}}<a href="{{.SiteURL}}">site </a>{{else}}<span class=muted></span>{{end}}</td>
<td>📂 <a href="{{.FilesURL}}">~{{.Name}}/public</a></td>
<td class=right>{{.UsedH}}</td></tr>{{end}}
{{if not .Peers}}<tr><td colspan=4 class=muted>No members yet.</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>
<p class=muted style="margin-top:20px">Publish over SFTP: <code>scp file files@{{.Title}}:/me/public/</code> appears at <code>~yourname/public</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=bar><h1>{{.Title}}</h1><div class=muted><a href="/">members</a> · <a href="/login">Sign in</a></div></div>
<div class=crumbs><b>{{.CurPath}}</b></div>
<p class=muted>Read-only public area.</p>
<p class=muted>Read-only public files.</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>

View file

@ -162,22 +162,29 @@ 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")
// alice publishes to her own public folder (/me/public) and to /public.
uploadTo(t, h, cookie, "/me/public", "hello.txt", "from alice")
uploadTo(t, h, cookie, "/public", "shared.txt", "shared file")
// The unauthenticated root lists ~alice (a public-site directory), not a wall.
// The unauthenticated root lists every member with a link to ~alice/public.
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)
if body := rr.Body.String(); !strings.Contains(body, "/~alice/public/") {
t.Fatalf("index missing ~alice/public link: %.300s", body)
}
// Anonymous (no cookie) can download a member's published site file.
// Anonymous (no cookie) can download a member's published 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)
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/public/hello.txt", nil))
if got, _ := io.ReadAll(rr.Body); string(got) != "from alice" {
t.Fatalf("~alice/public file: got %q", got)
}
// Bare ~alice redirects to ~alice/public/.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice", nil))
if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/~alice/public/" {
t.Fatalf("~alice: want redirect to /~alice/public/, got %d %q", rr.Code, rr.Header().Get("Location"))
}
// Anonymous can download from the shared public area via a clean URL.
@ -189,16 +196,16 @@ func TestWebAnonPublicSite(t *testing.T) {
// Anonymous directory browse renders a listing.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/", nil))
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/public/", nil))
if body := rr.Body.String(); !strings.Contains(body, "hello.txt") {
t.Fatalf("~alice browse missing file: %.300s", body)
t.Fatalf("~alice/public browse missing file: %.300s", body)
}
}
func TestWebAnonMemberSiteEmptyNot404(t *testing.T) {
// A registered member who has not published anything yet (no site dir on
// disk) is reachable at ~name as an empty listing, not a 404. A missing file
// under them, and an unknown member, both still 404.
// A registered member who has not published anything yet (no public folder on
// disk) is reachable at ~name/public as an empty listing, not a 404. A missing
// file under them, and an unknown member, both still 404.
svc, st, _ := newTestService(t)
if _, err := st.EnsureUser("bob", "member", "SHA256:bobkey"); err != nil {
t.Fatal(err)
@ -206,22 +213,29 @@ func TestWebAnonMemberSiteEmptyNot404(t *testing.T) {
h := svc.WebHandler(WebConfig{Title: "files.test"})
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/", nil))
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/public/", nil))
if rr.Code != http.StatusOK {
t.Fatalf("~bob (member, empty site): want 200, got %d", rr.Code)
t.Fatalf("~bob/public (member, empty): want 200, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "(empty)") {
t.Fatalf("~bob should render an empty listing: %.200s", rr.Body.String())
t.Fatalf("~bob/public should render an empty listing: %.200s", rr.Body.String())
}
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/nope.txt", nil))
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/public/nope.txt", nil))
if rr.Code != http.StatusNotFound {
t.Fatalf("~bob/nope.txt: want 404, got %d", rr.Code)
t.Fatalf("~bob/public/nope.txt: want 404, got %d", rr.Code)
}
// Anything under ~bob that is not /public is not exposed.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/secret", nil))
if rr.Code != http.StatusNotFound {
t.Fatalf("~bob/secret: want 404, got %d", rr.Code)
}
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~nobody/", nil))
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~nobody/public/", nil))
if rr.Code != http.StatusNotFound {
t.Fatalf("~nobody (unknown): want 404, got %d", rr.Code)
}
@ -232,13 +246,15 @@ func TestWebAnonCannotEscape(t *testing.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.
// Only ~name/public is exposed; traversal out of a public area must not reach
// the private home or anything above it.
for _, p := range []string{
"/~alice/../../users/alice/secret.txt",
"/~alice/public/../../secret.txt",
"/~alice/public/../../../users/alice/secret.txt",
"/public/../users/alice/secret.txt",
"/~alice/..%2f..%2fusers%2falice%2fsecret.txt",
"/~ghost/anything", // unknown member
"/~alice/public/..%2f..%2fsecret.txt",
"/~alice/secret.txt", // not under /public
"/~ghost/public/x", // unknown member
} {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))

View file

@ -700,19 +700,21 @@ FILES_SITE="
${FILES_DOMAIN} {
encode zstd gzip
# Everything is served by the agentbbs file-manager on loopback. It serves,
# all from the same virtual storage as SFTP:
# / public directory of members' ~user sites (no auth)
# /~<name>[/...] a member's public /site — anon read-only browse + files
# A pure file server (NOT a website host — member homepages live on the BBS
# at https://${DOMAIN}/~<name>). The agentbbs file-manager on loopback serves:
# / directory of all members (links to each one's BBS site
# and their public files here) — no auth
# /~<name>/public[/] a member's public files folder — anon read-only browse
# /public[/...] the shared public area — anon read-only browse + files
# (signed in) the member's private /me, their /site, and /public
# (signed in) the member's private /me (whose public/ subdir is the
# ~name/public surface) and the shared /public
# Clean URLs map 1:1 to the SFTP paths, so share links just work:
# scp dist.crx files@${FILES_DOMAIN}:/public/extensions/acme/
# -> https://${FILES_DOMAIN}/public/extensions/acme/dist.crx
# scp index.html files@${FILES_DOMAIN}:/site/
# -> https://${FILES_DOMAIN}/~<name>/index.html
# The anon surface has no route to a member's private /me (see internal/files
# web tests); it is structurally read-only and area-confined.
# scp index.html files@${FILES_DOMAIN}:/me/public/
# -> https://${FILES_DOMAIN}/~<name>/public/index.html
# The anon surface only ever exposes ~name/public, never the rest of a
# member's private /me (see internal/files web tests); it is read-only.
reverse_proxy http://${FILES_WEB_ADDR}
}
"