From d19c5c4c3e9b6cdcdb04dd5f9fc1f4f7b897e8f7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 1 Jul 2026 02:43:27 +0000 Subject: [PATCH] files: seed a default README.txt into every member's public area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureUserPub only ran os.MkdirAll, so a freshly-provisioned /public (and thus ~/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 ~/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 --- internal/files/backend.go | 26 +++++++++++++++-- internal/files/default_readme.txt | 46 +++++++++++++++++++++++++++++++ internal/files/files_test.go | 37 +++++++++++++++++++++++-- internal/files/web_test.go | 12 ++++---- 4 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 internal/files/default_readme.txt diff --git a/internal/files/backend.go b/internal/files/backend.go index 0193310..7092c49 100644 --- a/internal/files/backend.go +++ b/internal/files/backend.go @@ -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 ~/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 ~/public. +// (0o755) because the web host serves it anonymously at ~/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 diff --git a/internal/files/default_readme.txt b/internal/files/default_readme.txt new file mode 100644 index 0000000..9a86146 --- /dev/null +++ b/internal/files/default_readme.txt @@ -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 + ~/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 +======================================================================== diff --git a/internal/files/files_test.go b/internal/files/files_test.go index 6a06b53..8f22b35 100644 --- a/internal/files/files_test.go +++ b/internal/files/files_test.go @@ -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 // (/public/) — 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) } } diff --git a/internal/files/web_test.go b/internal/files/web_test.go index 64b12b3..e4ec5c9 100644 --- a/internal/files/web_test.go +++ b/internal/files/web_test.go @@ -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()