From f2bcb7e0630c7b54d35aec6447ab4c42717ab491 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 26 Jun 2026 01:36:43 +0000 Subject: [PATCH] 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, /files/users/) and /public (their own public files, /files/public/), served anonymously at ~/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. stays a file server; member sites remain on the BBS. Co-Authored-By: Claude Opus 4.8 --- docs/files.md | 34 ++++++++----------- internal/files/backend.go | 66 +++++++++++++++++++++--------------- internal/files/files_test.go | 51 ++++++++++++++-------------- internal/files/fs.go | 51 +++++++++++++++------------- internal/files/web.go | 21 +++++------- internal/files/web_test.go | 30 ++++++++-------- setup.sh | 17 ++++------ 7 files changed, 135 insertions(+), 135 deletions(-) diff --git a/docs/files.md b/docs/files.md index baa00ba..50e9762 100644 --- a/docs/files.md +++ b/docs/files.md @@ -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 `~/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 **`~/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 **`~/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.\ is a **file server, not a website host**. Member homepages > ("sites") live on the BBS at `https:///~`; 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 `/files/{users,public}` (a member's public files are `users//public`) | +| `AGENTBBS_DATA` | `./data` | storage lives under `/files/{users,public}` — private `/me` is `users/`, public `/public` is `public/` | ## 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 | -| `/~/public[/path]` | That member's public files (`/me/public`) — browse + download | none | +| `/~/public[/path]` | That member's own public files (`/public`) — browse + download | none | | `/~` | Redirects to `/~/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 ``` diff --git a/internal/files/backend.go b/internal/files/backend.go index c853873..0193310 100644 --- a/internal/files/backend.go +++ b/internal/files/backend.go @@ -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 (/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 ~/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: /public/. 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 ~/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 ~/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 +// ~/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 } diff --git a/internal/files/files_test.go b/internal/files/files_test.go index 580c3db..6a06b53 100644 --- a/internal/files/files_test.go +++ b/internal/files/files_test.go @@ -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 /public/. 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 + // (/public/) — 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) } } diff --git a/internal/files/fs.go b/internal/files/fs.go index fde360f..267a095 100644 --- a/internal/files/fs.go +++ b/internal/files/fs.go @@ -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 ~/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 ~/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 ~/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 "aWriter{f: f, sess: s, tracked: startSize}, nil diff --git a/internal/files/web.go b/internal/files/web.go index c0ea1a6..580fde9 100644 --- a/internal/files/web.go +++ b/internal/files/web.go @@ -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 /~/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 /~/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: /~/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(`{{end}} {{if not .Entries}}(empty){{end}} -

Files in /me/public are public at ~{{.User}}/public. Reachable over SFTP with your SSH key: sftp files@{{.Title}}.

+

Your /public area is public at ~{{.User}}/public; /me stays private. Reachable over SFTP with your SSH key: sftp files@{{.Title}}.

`)) type indexPeer struct { @@ -623,7 +618,7 @@ type anonData struct { var indexTmpl = template.Must(template.New("index").Parse(` {{.Title}}
-

{{.Title}}

+

{{.Title}}

{{if .Err}}
{{.Err}}
{{end}}

Member directory. Each member has a site (their homepage on the BBS) and a public files folder here. Sign in to manage your own files.

@@ -633,7 +628,7 @@ var indexTmpl = template.Must(template.New("index").Parse(`{{end}} {{if not .Peers}}{{end}}
MemberSitePublic filesSize
{{.UsedH}}
No members yet.
-

Publish over SFTP: scp file files@{{.Title}}:/me/public/ → appears at ~yourname/public.

+

Publish over SFTP: scp file files@{{.Title}}:/public/ → appears at ~yourname/public. (/me stays private.)

`)) var anonTmpl = template.Must(template.New("anon").Parse(` diff --git a/internal/files/web_test.go b/internal/files/web_test.go index 19180aa..64b12b3 100644 --- a/internal/files/web_test.go +++ b/internal/files/web_test.go @@ -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)) diff --git a/setup.sh b/setup.sh index d66c7dc..36af55d 100755 --- a/setup.sh +++ b/setup.sh @@ -704,17 +704,14 @@ ${FILES_DOMAIN} { # at https://${DOMAIN}/~). 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 - # /~/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/ + # /~/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 ~/public). Clean URLs map 1:1 to the SFTP paths: + # scp index.html files@${FILES_DOMAIN}:/public/ # -> https://${FILES_DOMAIN}/~/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} } "