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") addr := env("AGENTBBS_FILES_WEB_ADDR", "127.0.0.1:8092")
title := env("AGENTBBS_FILES_WEB_TITLE", "files."+strings.TrimPrefix(a.host, "bbs.")) 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} srv := &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}
go func() { go func() {
log.Info("files web listening", "addr", addr) 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) - 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.
## 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 | | 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 |
`/me` is private — there is **no** path from one member's `/me` to another's. The Your `/me` home has one special subfolder: **`/me/public`**. Anything you put
two *public* surfaces are `/site` (per-member, the only thing exposed at there is published on the web at **`~<name>/public`** (the unix `~/public`
`~<name>`) and the single shared `/public`. All three areas are confined: a path convention) — that, and only that, is the per-member public surface. The rest of
that tries to escape its root (`../`, an absolute path, or a planted symlink) is `/me` stays private; there is **no** path from one member's `/me` to another's.
rejected, and the unauthenticated web surface (below) has no route into anyone's Both areas are confined: a path that tries to escape its root (`../`, an absolute
`/me`. 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 ## Quotas
Each member's **owned storage** has a byte quota (default **1 GiB**, set by Each private workspace has a byte quota (default **1 GiB**, set by
`AGENTBBS_FILES_QUOTA_MB`). The gauge sums your private `/me` **and** your public `AGENTBBS_FILES_QUOTA_MB`). The gauge measures all of `/me` — including your
`/site`; writes to either that would exceed the quota fail. Operators can set a `/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 per-user override in the management TUI. The shared `/public` area is
operator-managed and **not** metered per user. 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` | `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,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) ## Provisioning members from a public key (for external services)
@ -124,10 +128,11 @@ has no route into anyone's `/me`.
| URL | Serves | Auth | | URL | Serves | Auth |
|---|---|---| |---|---|---|
| `/` | A **directory of members' `~user` sites**, plus a sign-in link | none | | `/` | A **directory of all members** — each linked to their BBS **site** and their **public files** | none |
| `/~<name>[/path]` | That member's public `/site` — browse + download | 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 | | `/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: 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/ scp dist.crx files@files.profullstack.com:/public/extensions/acme/
-> https://files.profullstack.com/public/extensions/acme/dist.crx -> https://files.profullstack.com/public/extensions/acme/dist.crx
scp index.html files@files.profullstack.com:/site/ scp index.html files@files.profullstack.com:/me/public/
-> https://files.profullstack.com/~chovy/index.html -> https://files.profullstack.com/~chovy/public/index.html
``` ```
Directories render a read-only browse listing; files stream with a content type 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.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, "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 { if err := os.MkdirAll(d, 0o755); err != nil {
return nil, err return nil, err
} }
@ -96,10 +96,11 @@ 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, // publicHome is the member's own public file folder: the public/ subdirectory of
// served unauthenticated at ~<name> on the web file host. // their private home. It is exposed anonymously on the web at ~<name>/public
func (s *Service) siteRoot(user string) string { // (the unix ~/public convention) and metered as part of /me.
return filepath.Join(s.cfg.Root, "sites", user) func (s *Service) publicHome(user string) string {
return filepath.Join(s.privRoot(user), "public")
} }
// ensureWorkspace creates a member's private workspace if absent. // 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) return os.MkdirAll(s.privRoot(user), 0o700)
} }
// ensureSite creates a member's public site directory if absent. It is // ensurePublicHome creates a member's ~/public folder if absent (and the home
// world-readable (0o755) because the web host serves it anonymously at ~<name>. // above it). The home stays private (0o700); the public/ subdir is the only
func (s *Service) ensureSite(user string) error { // part the web host exposes anonymously, and the Go server reads it directly.
return os.MkdirAll(s.siteRoot(user), 0o755) func (s *Service) ensurePublicHome(user string) error {
} if err := s.ensureWorkspace(user); err != nil {
return err
// 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)) return os.MkdirAll(s.publicHome(user), 0o755)
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
@ -184,40 +174,39 @@ 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 // Member is a member listed in the anonymous ~user directory at the root of the
// directory index on the web file host. // web file host. PublicBytes is the size of their ~/public folder.
type SitePeer struct { type Member struct {
Name string Name string
Bytes int64 PublicBytes int64
} }
// PublicSites lists members who have published anything to their public /site, // Members lists every (non-banned) account, sorted by name — the source for the
// sorted by name — the source for the anonymous ~user directory at the root of // anonymous ~user directory at the root of the web file host. Every member is
// the web file host. Members with an empty site are omitted. // listed (it is a real directory), each with a link to their site and to their
func (s *Service) PublicSites() ([]SitePeer, error) { // public files; PublicBytes shows how much they have published.
func (s *Service) Members() ([]Member, error) {
users, err := s.st.ListUsers(10000) users, err := s.st.ListUsers(10000)
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := make([]SitePeer, 0, len(users)) out := make([]Member, 0, len(users))
for _, u := range users { for _, u := range users {
if u.Banned { if u.Banned {
continue continue
} }
n, err := dirSize(s.siteRoot(u.Name)) n, _ := dirSize(s.publicHome(u.Name))
if err != nil || n == 0 { out = append(out, Member{Name: u.Name, PublicBytes: n})
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 }) sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil return out, nil
} }
// AnonRoot resolves the on-disk root for an anonymous, read-only browse target: // 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 // the shared public area (name == "") or a member's public files folder, i.e.
// ~handle). ok is false when the named member does not exist or is banned. The // ~/public (name = the ~handle). ok is false when the named member does not
// returned root is a confinement boundary — callers must safeJoin onto it. // 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) { func (s *Service) AnonRoot(name string) (root string, ok bool, err error) {
if name == "" { if name == "" {
return s.pubRoot(), true, nil 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 { if !found || u.Banned {
return "", false, nil return "", false, nil
} }
// Materialize the (idempotent) site dir so ~name is browsable the moment the // Materialize the (idempotent) ~/public folder so ~name/public is browsable
// account exists — before the member's first SFTP/web session creates it. // the moment the account exists. Without this, joining onto a missing root
// Without this, joining onto a missing root trips the escape guard. // trips the escape guard.
if err := s.ensureSite(u.Name); err != nil { if err := s.ensurePublicHome(u.Name); err != nil {
return "", false, err 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) // SafeJoin exposes the area-confinement join (lexical + symlink-escape guard)
@ -256,10 +245,10 @@ func (u Usage) Free() int64 {
return u.Quota - u.Bytes return u.Quota - u.Bytes
} }
// Usage computes a member's owned-storage usage (private /me + public /site) // Usage computes a member's private-workspace usage (all of /me, which includes
// against their quota. // their ~/public folder) against their quota.
func (s *Service) Usage(u store.User) (Usage, error) { 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 { 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) != 3 || names[0] != "me" || names[1] != "public" || names[2] != "site" { if len(names) != 2 || names[0] != "me" || names[1] != "public" {
t.Errorf("root listing = %v, want [me public site]", names) 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) svc, _, u := newTestService(t)
if err := svc.ensureWorkspace(u.Name); err != nil { if err := svc.ensurePublicHome(u.Name); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := svc.ensureSite(u.Name); err != nil { // A member's ~/public folder lives inside their private /me home, so both the
t.Fatal(err) // top-level private file and the public file count toward the quota gauge;
} // the shared /public area does not.
// 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 { if err := os.WriteFile(filepath.Join(svc.privRoot(u.Name), "a.txt"), []byte(strings.Repeat("x", 512)), 0o644); err != nil {
t.Fatal(err) 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) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(svc.pubRoot(), "shared.txt"), []byte(strings.Repeat("z", 9999)), 0o644); err != nil { 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) t.Fatal(err)
} }
if usage.Bytes != 768 { 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. // 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 ( const (
areaMe = "me" // private, per-user workspace areaMe = "me"
areaSite = "site" // the member's own public root (served at ~<name>) areaPublic = "public"
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")
@ -43,13 +42,12 @@ type session struct {
} }
func (s *Service) newSession(u store.User) (*session, error) { 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 return nil, err
} }
if err := s.ensureSite(u.Name); err != nil { used, err := dirSize(s.privRoot(u.Name))
return nil, err
}
used, err := s.ownedUsage(u.Name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -84,10 +82,6 @@ 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:
@ -165,7 +159,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: 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) des, err := os.ReadDir(res.real)
if err != nil { if err != nil {
@ -309,8 +303,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) // shared public area is operator-managed, unmetered limit := int64(-1) // public area is operator-managed, unmetered
if metered(res.area) { if res.area == areaMe {
if limit = s.quota - (s.used.Load() - existing); limit < 0 { if limit = s.quota - (s.used.Load() - existing); limit < 0 {
limit = 0 limit = 0
} }
@ -324,7 +318,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 metered(res.area) { if res.area == areaMe {
s.used.Add(n - existing) s.used.Add(n - existing)
} }
return n, nil return n, nil
@ -386,9 +380,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 shared public area is operator-managed (no per-user quota); the // The public area is operator-managed (no per-user quota); the private
// member's own areas (/me and /site) are metered. // workspace is metered.
if !metered(res.area) { if res.area != areaMe {
return f, nil return f, nil
} }
return &quotaWriter{f: f, sess: s, tracked: startSize}, 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 { switch r.Method {
case "List": case "List":
if res.root { 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) entries, err := os.ReadDir(res.real)
if err != nil { if err != nil {

View file

@ -27,6 +27,10 @@ type WebConfig struct {
Authenticate func(user, pass string) (store.User, bool, error) Authenticate func(user, pass string) (store.User, bool, error)
// Title is shown in the page header (e.g. "files.profullstack.com"). // Title is shown in the page header (e.g. "files.profullstack.com").
Title string 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 defaults to 12h.
SessionTTL time.Duration 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}) _ = loginTmpl.Execute(w, loginData{Title: h.cfg.Title, Err: errMsg})
} }
// renderIndex serves the public landing page: a directory of members' ~user // renderIndex serves the public landing page: a directory of every member,
// sites (each linking to their public /site), plus a link to the shared /public // each linked to their site (homepage on the BBS) and their public files folder
// area and a sign-in link. No authentication required. // (~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) { func (h *webSrv) renderIndex(w http.ResponseWriter, errMsg string) {
data := indexData{Title: h.cfg.Title, Err: errMsg} data := indexData{Title: h.cfg.Title, Err: errMsg}
if peers, err := h.svc.PublicSites(); err == nil { if members, err := h.svc.Members(); err == nil {
for _, p := range peers { for _, m := range members {
data.Peers = append(data.Peers, indexPeer{Name: p.Name, URL: "/~" + p.Name + "/", UsedH: humanSize(p.Bytes)}) 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") 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 // handleAnon serves the unauthenticated, read-only public surface: the shared
// /public area and each member's public site at /~<name>. Directories render a // /public area and each member's public files at /~<name>/public. Directories
// browse listing; files stream with a content type and a short cache. It is // render a browse listing; files stream with a content type and a short cache.
// confined to the area root by the same safeJoin guard as SFTP — there is no // It is confined to the area root by the same safeJoin guard as SFTP — only a
// path to a member's private /me or above the area root. // member's ~/public subfolder is exposed, never the rest of their private /me.
func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) { func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead { if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 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 var name, rel, prefix, heading string
switch { switch {
case upath == "/public" || strings.HasPrefix(upath, "/public/"): case upath == "/public" || strings.HasPrefix(upath, "/public/"):
// The shared/global public area.
name, rel, prefix, heading = "", strings.TrimPrefix(upath, "/public"), "/public", "/public" name, rel, prefix, heading = "", strings.TrimPrefix(upath, "/public"), "/public", "/public"
case strings.HasPrefix(upath, "/~"): 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) seg := strings.SplitN(strings.TrimPrefix(upath, "/~"), "/", 2)
name = seg[0] name = seg[0]
if name == "" { if name == "" {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }
after := ""
if len(seg) == 2 { 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: default:
http.NotFound(w, r) http.NotFound(w, r)
return return
@ -386,10 +413,9 @@ func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) {
} }
fi, err := os.Stat(real) fi, err := os.Stat(real)
if err != nil { if err != nil {
// A known member whose site dir hasn't been created yet (it is created // A known member with an empty/not-yet-created ~/public folder renders as
// lazily on their first files session) renders as an empty listing — not // an empty listing — not a 404 — so ~name/public is reachable the moment
// a 404 — so ~name is reachable as soon as the account exists. A missing // the account exists. A missing sub-path still 404s.
// sub-path still 404s.
if os.IsNotExist(err) && path.Clean("/"+strings.TrimPrefix(rel, "/")) == "/" { if os.IsNotExist(err) && path.Clean("/"+strings.TrimPrefix(rel, "/")) == "/" {
h.renderAnonDir(w, prefix, heading, rel, real) h.renderAnonDir(w, prefix, heading, rel, real)
return return
@ -562,13 +588,14 @@ 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">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>`)) </div></body></html>`))
type indexPeer struct { type indexPeer struct {
Name string Name string
URL string FilesURL string // /~name/public/
UsedH string SiteURL string // https://bbs.../~name (empty if no SiteBase)
UsedH string
} }
type indexData struct { 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> 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> <meta name=viewport content="width=device-width,initial-scale=1"><title>{{.Title}}</title><style>` + baseCSS + `</style></head>
<body><div class=wrap> <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}} {{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> <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 class=right>Size</th></tr> <table><tr><th>Member</th><th>Site</th><th>Public files</th><th class=right>Size</th></tr>
{{range .Peers}}<tr><td>📂 <a href="{{.URL}}">~{{.Name}}</a></td><td class=right>{{.UsedH}}</td></tr>{{end}} {{range .Peers}}<tr><td>👤 {{.Name}}</td>
{{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}} <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> </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>`)) </div></body></html>`))
var anonTmpl = template.Must(template.New("anon").Parse(`<!doctype html><html><head><meta charset=utf-8> 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> <meta name=viewport content="width=device-width,initial-scale=1"><title>{{.CurPath}} · {{.Title}}</title><style>` + baseCSS + `</style></head>
<body><div class=wrap> <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> <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> <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}} {{if .UpOK}}<tr><td><a href="{{.UpURL}}"> ..</a></td><td></td><td></td></tr>{{end}}
{{range .Entries}}<tr> {{range .Entries}}<tr>

View file

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

View file

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