files: seed a default README.txt into every member's public area

ensureUserPub only ran os.MkdirAll, so a freshly-provisioned /public
(and thus ~<name>/public on the web) came up empty — only ~chovy had a
README because it was uploaded by hand. Embed that help text as a
default and write it whenever the area has no README.txt.

ensureUserPub is hit on SFTP connect (fs.go) and when the web host
materializes ~<name>/public (AnonRoot), so this self-heals every
existing empty member the next time they connect or their page is
viewed — no manual backfill. A member's own README is never clobbered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-07-01 02:43:27 +00:00
parent 042166b05f
commit d19c5c4c3e
4 changed files with 111 additions and 10 deletions

View file

@ -23,6 +23,7 @@
package files
import (
_ "embed"
"io/fs"
"os"
"path/filepath"
@ -34,6 +35,15 @@ import (
"github.com/profullstack/agentbbs/internal/store"
)
// defaultPublicReadme seeds every member's public area so ~<name>/public is
// never a bare "(empty)" listing — it explains the SFTP endpoint and the two
// areas. Written on first materialization of the area (and re-seeded if absent),
// so it self-heals existing members the next time they connect or their page is
// viewed. Members are free to delete or replace it.
//
//go:embed default_readme.txt
var defaultPublicReadme []byte
// Setting keys persisted in files_settings.
const (
// settingPublicWrite is "members" (default) or "off". When "off", the
@ -111,9 +121,21 @@ func (s *Service) ensureWorkspace(user string) error {
}
// ensureUserPub creates a member's public area if absent. It is world-readable
// (0o755) because the web host serves it anonymously at ~<name>/public.
// (0o755) because the web host serves it anonymously at ~<name>/public. It also
// seeds a default README.txt when the area has none, so a freshly-provisioned
// (or previously-empty) public listing greets visitors with the SFTP how-to
// instead of "(empty)". Members may delete or overwrite it freely.
func (s *Service) ensureUserPub(user string) error {
return os.MkdirAll(s.userPub(user), 0o755)
dir := s.userPub(user)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
readme := filepath.Join(dir, "README.txt")
if _, err := os.Stat(readme); os.IsNotExist(err) {
// Best-effort: a seed failure must not block file access.
_ = os.WriteFile(readme, defaultPublicReadme, 0o644)
}
return nil
}
// ownedUsage sums a member's two owned areas — their private /me and their

View file

@ -0,0 +1,46 @@
========================================================================
AgentBBS Files - SFTP file area
========================================================================
Connect with your BBS SSH key (the same key you joined with):
sftp files@bbs.profullstack.com
# or point at a specific key:
sftp -i ~/.ssh/your_bbs_key files@bbs.profullstack.com
The username is always "files" - your identity is your SSH KEY, not the
name you type. scp and rsync ride the same endpoint:
scp -O notes.txt files@bbs.profullstack.com:/me/
rsync -ave ssh ./site/ files@bbs.profullstack.com:/me/site/
------------------------------------------------------------------------
Two areas (this is ALL you can see - no home dir is ever exposed)
------------------------------------------------------------------------
/me Your private workspace. Only you can read or write it.
1 GB quota by default.
/public Your own public file area, published on the web at
~<name>/public. Everyone reads it anonymously; only you
write it.
There is NO access to any home directory, the host filesystem, or other
members' workspaces. This is a fully virtual SFTP server.
------------------------------------------------------------------------
Quick test
------------------------------------------------------------------------
sftp files@bbs.profullstack.com
sftp> ls / # shows: me public
sftp> cd /me
sftp> put somefile.txt
sftp> ls
sftp> cd /public
sftp> get README.txt
sftp> bye
Not a member yet? ssh join@bbs.profullstack.com
========================================================================

View file

@ -1,6 +1,7 @@
package files
import (
"bytes"
"os"
"path/filepath"
"strings"
@ -10,6 +11,32 @@ import (
"github.com/profullstack/agentbbs/internal/store"
)
func TestEnsureUserPubSeedsReadme(t *testing.T) {
svc, _, u := newTestService(t)
if err := svc.ensureUserPub(u.Name); err != nil {
t.Fatal(err)
}
readme := filepath.Join(svc.userPub(u.Name), "README.txt")
got, err := os.ReadFile(readme)
if err != nil {
t.Fatalf("public area not seeded with README.txt: %v", err)
}
if !bytes.Equal(got, defaultPublicReadme) {
t.Errorf("seeded README content does not match the embedded default")
}
// Re-materialization must not clobber a member's own README.
custom := []byte("this is my own readme, hands off\n")
if err := os.WriteFile(readme, custom, 0o644); err != nil {
t.Fatal(err)
}
if err := svc.ensureUserPub(u.Name); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(readme); !bytes.Equal(got, custom) {
t.Errorf("ensureUserPub overwrote the member's own README: %q", got)
}
}
func newTestService(t *testing.T) (*Service, store.Store, store.User) {
t.Helper()
dir := t.TempDir()
@ -124,7 +151,8 @@ func TestOwnPublicWritable(t *testing.T) {
func TestQuotaEnforced(t *testing.T) {
svc, _, u := newTestService(t)
sess, _ := svc.newSession(u)
sess.quota = 100 // tiny
sess.quota = 100 // tiny
sess.used.Store(0) // isolate the writer from the seeded-README baseline
f, err := os.Create(filepath.Join(svc.privRoot(u.Name), "big"))
if err != nil {
@ -170,6 +198,9 @@ func TestUsageCountsOwnedAreas(t *testing.T) {
if err := svc.ensureUserPub(u.Name); err != nil {
t.Fatal(err)
}
// ensureUserPub seeds a default README.txt into the public area; it counts
// toward the gauge like any other public file.
seed := int64(len(defaultPublicReadme))
// 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.
@ -189,8 +220,8 @@ func TestUsageCountsOwnedAreas(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if usage.Bytes != 768 {
t.Errorf("usage = %d, want 768 (512 /me + 256 /public, other members excluded)", usage.Bytes)
if want := int64(768) + seed; usage.Bytes != want {
t.Errorf("usage = %d, want %d (512 /me + 256 /public + seeded README, other members excluded)", usage.Bytes, want)
}
}

View file

@ -202,9 +202,11 @@ func TestWebAnonPublicSite(t *testing.T) {
}
func TestWebAnonMemberSiteEmptyNot404(t *testing.T) {
// A registered member who has not published anything yet (no public folder on
// disk) is reachable at ~name/public as an empty listing, not a 404. A missing
// file under them, and an unknown member, both still 404.
// A registered member who has not published anything yet is reachable at
// ~name/public as a browsable listing, not a 404 — materializing the area
// seeds a default README.txt, so the listing greets visitors with it rather
// than "(empty)". A missing file under them, and an unknown member, both
// still 404.
svc, st, _ := newTestService(t)
if _, err := st.EnsureUser("bob", "member", "SHA256:bobkey"); err != nil {
t.Fatal(err)
@ -216,8 +218,8 @@ func TestWebAnonMemberSiteEmptyNot404(t *testing.T) {
if rr.Code != http.StatusOK {
t.Fatalf("~bob/public (member, empty): want 200, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "(empty)") {
t.Fatalf("~bob/public should render an empty listing: %.200s", rr.Body.String())
if !strings.Contains(rr.Body.String(), "README.txt") {
t.Fatalf("~bob/public should render the seeded README in its listing: %.200s", rr.Body.String())
}
rr = httptest.NewRecorder()