files: /me and /public are two separate per-user areas

Per feedback: /me is PRIVATE and the public folder must be its own
top-level area, not nested under /me.

- A member now has two sibling areas over SFTP: /me (private,
  <root>/files/users/<name>) and /public (their own public files,
  <root>/files/public/<name>), served anonymously at ~<name>/public.
- Drop the global shared /public web route and the /me/public nesting.
  The anon surface only exposes ~name/public; /me has no anon route.
- Both owned areas count toward the quota gauge.
- Index publish hint, docs, and setup.sh updated to scp :/public/.

files.<host> stays a file server; member sites remain on the BBS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-26 01:36:43 +00:00
parent a2d4817a8e
commit f2bcb7e063
7 changed files with 135 additions and 135 deletions

View file

@ -38,15 +38,14 @@ When you connect you see a virtual root with two directories:
| Path | What it is | Access |
|---|---|---|
| `/me` | Your **private** per-user workspace | read/write, quota-limited |
| `/public` | The single **shared public file area** (old-school BBS file area) | world-read; members-only write by default |
| `/public` | Your **own public files** area, published at `~<name>/public` | read/write (yours); world-read anonymously |
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
These are two **separate** areas — `/public` is a sibling of `/me`, **not** a
folder inside it. `/me` stays fully private; anything you put in `/public` is
published on the web at **`~<name>/public`**. Both count toward your quota. 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`.
(below) only ever exposes `~name/public`, never your private `/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`
@ -54,11 +53,10 @@ path, or a planted symlink) is rejected, and the unauthenticated web surface
## Quotas
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.
Each member has a byte quota (default **1 GiB**, set by
`AGENTBBS_FILES_QUOTA_MB`). The gauge sums both of your areas — private `/me`
**and** your public `/public` — and writes that would exceed it fail. Operators
can set a per-user override in the management TUI.
## In-BBS browser
@ -94,7 +92,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,public}` (a member's public files are `users/<name>/public`) |
| `AGENTBBS_DATA` | `./data` | storage lives under `<data>/files/{users,public}` — private `/me` is `users/<name>`, public `/public` is `public/<name>` |
## Provisioning members from a public key (for external services)
@ -129,18 +127,14 @@ has no route into anyone's `/me`.
| URL | Serves | Auth |
|---|---|---|
| `/` | 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>/public[/path]` | That member's own public files (`/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` + the shared `/public` | webmail login |
| `/?path=…`, `/upload`, … | The authenticated manager: your `/me` and your `/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:/me/public/
scp index.html files@files.profullstack.com:/public/
-> https://files.profullstack.com/~chovy/public/index.html
```

View file

@ -93,14 +93,16 @@ func (s *Service) privRoot(user string) string {
return filepath.Join(s.cfg.Root, "users", user)
}
// pubRoot is the absolute shared public-area directory.
// pubRoot is the parent of the per-user public areas (<root>/public). Each
// member's own public files live in a subdirectory keyed by handle; this parent
// is what the operator moderation pane lists.
func (s *Service) pubRoot() string { return filepath.Join(s.cfg.Root, "public") }
// 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")
// userPub is a member's own public file area: <root>/public/<name>. It is a
// top-level area in its own right (sibling to the private /me, NOT nested under
// it) and is served anonymously on the web at ~<name>/public.
func (s *Service) userPub(user string) string {
return filepath.Join(s.pubRoot(), user)
}
// ensureWorkspace creates a member's private workspace if absent.
@ -108,14 +110,24 @@ func (s *Service) ensureWorkspace(user string) error {
return os.MkdirAll(s.privRoot(user), 0o700)
}
// 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
// ensureUserPub creates a member's public area if absent. It is world-readable
// (0o755) because the web host serves it anonymously at ~<name>/public.
func (s *Service) ensureUserPub(user string) error {
return os.MkdirAll(s.userPub(user), 0o755)
}
// ownedUsage sums a member's two owned areas — their private /me and their
// public /public — for the quota gauge.
func (s *Service) ownedUsage(user string) (int64, error) {
priv, err := dirSize(s.privRoot(user))
if err != nil {
return 0, err
}
return os.MkdirAll(s.publicHome(user), 0o755)
pub, err := dirSize(s.userPub(user))
if err != nil {
return 0, err
}
return priv + pub, nil
}
// quotaFor returns the effective quota (bytes) for a user: their per-user
@ -195,7 +207,7 @@ func (s *Service) Members() ([]Member, error) {
if u.Banned {
continue
}
n, _ := dirSize(s.publicHome(u.Name))
n, _ := dirSize(s.userPub(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 })
@ -203,13 +215,13 @@ func (s *Service) Members() ([]Member, error) {
}
// AnonRoot resolves the on-disk root for an anonymous, read-only browse target:
// 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.
// a member's own public area /public (name = the ~handle), served at
// ~<name>/public. 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 member's private /me.
func (s *Service) AnonRoot(name string) (root string, ok bool, err error) {
if name == "" {
return s.pubRoot(), true, nil
return "", false, nil
}
u, found, err := s.st.UserByName(name)
if err != nil {
@ -218,13 +230,13 @@ func (s *Service) AnonRoot(name string) (root string, ok bool, err error) {
if !found || u.Banned {
return "", false, 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 {
// Materialize the (idempotent) public area so ~name/public is browsable the
// moment the account exists. Without this, joining onto a missing root trips
// the escape guard.
if err := s.ensureUserPub(u.Name); err != nil {
return "", false, err
}
return s.publicHome(u.Name), true, nil
return s.userPub(u.Name), true, nil
}
// SafeJoin exposes the area-confinement join (lexical + symlink-escape guard)
@ -245,10 +257,10 @@ func (u Usage) Free() int64 {
return u.Quota - u.Bytes
}
// Usage computes a member's private-workspace usage (all of /me, which includes
// their ~/public folder) against their quota.
// Usage computes a member's owned-storage usage (private /me + public /public)
// against their quota.
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 {
return Usage{}, err
}

View file

@ -103,28 +103,21 @@ func TestSymlinkEscapeBlocked(t *testing.T) {
}
}
func TestPublicWriteACL(t *testing.T) {
svc, st, u := newTestService(t)
func TestOwnPublicWritable(t *testing.T) {
svc, _, u := newTestService(t)
// Default: members may write to the public area.
// A member's /public is their own area (served at ~name/public) — they can
// always write to it, and it resolves under <root>/public/<name>.
sess, _ := svc.newSession(u)
r := sftp.NewRequest("Mkdir", "/public/uploads")
if err := sess.Filecmd(r); err != nil {
t.Fatalf("public mkdir should succeed by default: %v", err)
if err := sess.Filecmd(sftp.NewRequest("Mkdir", "/public/uploads")); err != nil {
t.Fatalf("own /public mkdir should succeed: %v", err)
}
// Turn public write off → writes denied, reads still fine.
if err := svc.SetPublicWrite(false); err != nil {
t.Fatal(err)
res, err := sess.resolve("/public/uploads")
if err != nil || !res.writable {
t.Fatalf("own /public should resolve writable: %+v err=%v", res, err)
}
_ = st
sess2, _ := svc.newSession(u)
if err := sess2.Filecmd(sftp.NewRequest("Mkdir", "/public/more")); err != sftp.ErrSSHFxPermissionDenied {
t.Errorf("public write should be denied when off, got %v", err)
}
res, err := sess2.resolve("/public/uploads")
if err != nil || res.writable {
t.Errorf("public should resolve read-only when write is off: %+v err=%v", res, err)
if want := svc.userPub(u.Name); !within(want, res.real) && res.real != want {
t.Errorf("/public resolved to %q, want under %q", res.real, want)
}
}
@ -169,21 +162,27 @@ func TestUsage(t *testing.T) {
}
}
func TestUsageCountsPublicHome(t *testing.T) {
func TestUsageCountsOwnedAreas(t *testing.T) {
svc, _, u := newTestService(t)
if err := svc.ensurePublicHome(u.Name); err != nil {
if err := svc.ensureWorkspace(u.Name); err != nil {
t.Fatal(err)
}
// 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 := svc.ensureUserPub(u.Name); err != nil {
t.Fatal(err)
}
// Both of the member's owned areas — private /me and their public /public
// (<root>/public/<name>) — count toward the quota gauge. Another member's
// 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.publicHome(u.Name), "b.txt"), []byte(strings.Repeat("y", 256)), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(svc.userPub(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 {
if err := os.MkdirAll(svc.userPub("someoneelse"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(svc.userPub("someoneelse"), "x.txt"), []byte(strings.Repeat("z", 9999)), 0o644); err != nil {
t.Fatal(err)
}
usage, err := svc.Usage(u)
@ -191,7 +190,7 @@ func TestUsageCountsPublicHome(t *testing.T) {
t.Fatal(err)
}
if usage.Bytes != 768 {
t.Errorf("usage = %d, want 768 (512 /me + 256 /me/public, shared /public excluded)", usage.Bytes)
t.Errorf("usage = %d, want 768 (512 /me + 256 /public, other members excluded)", usage.Bytes)
}
}

View file

@ -15,17 +15,19 @@ import (
"github.com/profullstack/agentbbs/internal/store"
)
// 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.
// area names exposed at the virtual root. A member has two separate top-level
// areas: /me (private) and /public (their own public files, served anonymously
// on the web at ~<name>/public). /public is a sibling of /me, never nested
// inside it — /me stays fully private.
const (
areaMe = "me"
areaPublic = "public"
)
// metered reports whether writes to an area count against the member's quota.
// Both of a member's owned areas (/me and their /public) are metered.
func metered(area string) bool { return area == areaMe || area == areaPublic }
// 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")
@ -34,24 +36,26 @@ var errEscape = errors.New("files: path escapes its area")
// connection. It implements the pkg/sftp request handlers and enforces area
// confinement, the public-area ACL, and the per-user quota.
type session struct {
svc *Service
user store.User
pubWrite bool
quota int64
used atomic.Int64 // live private-workspace usage, for quota checks
svc *Service
user store.User
quota int64
used atomic.Int64 // live owned-storage usage (/me + /public), for quota checks
}
func (s *Service) newSession(u store.User) (*session, error) {
// 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 {
// Ensure both of the member's areas exist: their private /me and their own
// public /public (a sibling, not a subfolder of /me).
if err := s.ensureWorkspace(u.Name); err != nil {
return nil, err
}
used, err := dirSize(s.privRoot(u.Name))
if err := s.ensureUserPub(u.Name); err != nil {
return nil, err
}
used, err := s.ownedUsage(u.Name)
if err != nil {
return nil, err
}
sess := &session{svc: s, user: u, pubWrite: s.publicWritable(), quota: s.quotaFor(u.ID)}
sess := &session{svc: s, user: u, quota: s.quotaFor(u.ID)}
sess.used.Store(used)
return sess, nil
}
@ -83,7 +87,9 @@ func (s *session) resolve(p string) (resolved, error) {
case areaMe:
areaRoot, area, writable = s.svc.privRoot(s.user.Name), areaMe, true
case areaPublic:
areaRoot, area, writable = s.svc.pubRoot(), areaPublic, s.pubWrite
// The member's own public area (served anonymously at ~<name>/public);
// they read/write it.
areaRoot, area, writable = s.svc.userPub(s.user.Name), areaPublic, true
default:
return resolved{}, os.ErrNotExist
}
@ -303,8 +309,8 @@ func (s *session) webSave(vpath string, r io.Reader) (int64, error) {
if err != nil {
return 0, err
}
limit := int64(-1) // public area is operator-managed, unmetered
if res.area == areaMe {
limit := int64(-1)
if metered(res.area) {
if limit = s.quota - (s.used.Load() - existing); limit < 0 {
limit = 0
}
@ -318,7 +324,7 @@ func (s *session) webSave(vpath string, r io.Reader) (int64, error) {
if cerr != nil {
return 0, cerr
}
if res.area == areaMe {
if metered(res.area) {
s.used.Add(n - existing)
}
return n, nil
@ -380,9 +386,8 @@ func (s *session) Filewrite(r *sftp.Request) (io.WriterAt, error) {
if err != nil {
return nil, sftpErr(err)
}
// The public area is operator-managed (no per-user quota); the private
// workspace is metered.
if res.area != areaMe {
// Both of the member's owned areas (/me and their /public) are metered.
if !metered(res.area) {
return f, nil
}
return &quotaWriter{f: f, sess: s, tracked: startSize}, nil

View file

@ -69,8 +69,6 @@ func (s *Service) WebHandler(cfg WebConfig) http.Handler {
mux.HandleFunc("/upload", h.handleUpload)
mux.HandleFunc("/mkdir", h.handleMkdir)
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")) })
return mux
}
@ -357,11 +355,11 @@ func (h *webSrv) renderIndex(w http.ResponseWriter, errMsg string) {
_ = indexTmpl.Execute(w, data)
}
// handleAnon serves the unauthenticated, read-only public surface: the shared
// /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.
// handleAnon serves the unauthenticated, read-only public surface: each member's
// own 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 area is
// exposed, never 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)
@ -370,9 +368,6 @@ func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) {
upath := path.Clean("/" + strings.TrimPrefix(r.URL.Path, "/"))
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
@ -588,7 +583,7 @@ 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">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>
<p class=muted style="margin-top:20px">Your <code>/public</code> area is public at <a href="/~{{.User}}/public/">~{{.User}}/public</a>; <code>/me</code> stays private. Reachable over SFTP with your SSH key: <code>sftp files@{{.Title}}</code>.</p>
</div></body></html>`))
type indexPeer struct {
@ -623,7 +618,7 @@ 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/">shared /public</a> · <a href="/login">Sign in</a></div></div>
<div class=bar><h1>{{.Title}}</h1><div class=muted><a href="/login">Sign in</a></div></div>
{{if .Err}}<div class="flash err">{{.Err}}</div>{{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>
@ -633,7 +628,7 @@ var indexTmpl = template.Must(template.New("index").Parse(`<!doctype html><html>
<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}}:/me/public/</code> appears at <code>~yourname/public</code>.</p>
<p class=muted style="margin-top:20px">Publish over SFTP: <code>scp file files@{{.Title}}:/public/</code> appears at <code>~yourname/public</code>. (<code>/me</code> stays private.)</p>
</div></body></html>`))
var anonTmpl = template.Must(template.New("anon").Parse(`<!doctype html><html><head><meta charset=utf-8>

View file

@ -162,9 +162,8 @@ func TestWebAnonPublicSite(t *testing.T) {
h, _ := webTestHandler(t)
cookie := loginCookie(t, h)
// 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")
// alice publishes to her own public area (/public, a sibling of private /me).
uploadTo(t, h, cookie, "/public", "hello.txt", "from alice")
// The unauthenticated root lists every member with a link to ~alice/public.
rr := httptest.NewRecorder()
@ -187,19 +186,19 @@ func TestWebAnonPublicSite(t *testing.T) {
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.
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/public/", nil))
if body := rr.Body.String(); !strings.Contains(body, "hello.txt") {
t.Fatalf("~alice/public browse missing file: %.300s", body)
}
// /me stays private: there is no anonymous route into it.
rr = httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/me/", nil))
if rr.Code != http.StatusNotFound {
t.Fatalf("~alice/me: want 404 (private), got %d", rr.Code)
}
}
func TestWebAnonMemberSiteEmptyNot404(t *testing.T) {
@ -247,14 +246,13 @@ func TestWebAnonCannotEscape(t *testing.T) {
uploadTo(t, h, cookie, "/me", "secret.txt", "private")
// Only ~name/public is exposed; traversal out of a public area must not reach
// the private home or anything above it.
// the private /me or anything above it.
for _, p := range []string{
"/~alice/public/../../secret.txt",
"/~alice/public/../../users/alice/secret.txt",
"/~alice/public/../../../users/alice/secret.txt",
"/public/../users/alice/secret.txt",
"/~alice/public/..%2f..%2fsecret.txt",
"/~alice/secret.txt", // not under /public
"/~ghost/public/x", // unknown member
"/~alice/public/..%2f..%2fusers%2falice%2fsecret.txt",
"/~alice/me/secret.txt", // /me is private — not an anon surface
"/~ghost/public/x", // unknown member
} {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))

View file

@ -704,17 +704,14 @@ ${FILES_DOMAIN} {
# 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 (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}:/me/public/
# /~<name>/public[/] a member's own public files — anon read-only browse
# (signed in) the member's two areas: private /me and public /public
# A member has two SEPARATE areas: /me (private) and /public (their own public
# files, served at ~<name>/public). Clean URLs map 1:1 to the SFTP paths:
# scp index.html files@${FILES_DOMAIN}:/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.
# The anon surface only ever exposes ~name/public, never a member's private
# /me (see internal/files web tests); it is read-only.
reverse_proxy http://${FILES_WEB_ADDR}
}
"