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

@ -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))