mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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:
parent
042166b05f
commit
d19c5c4c3e
4 changed files with 111 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
46
internal/files/default_readme.txt
Normal file
46
internal/files/default_readme.txt
Normal 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
|
||||
========================================================================
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue