diff --git a/cmd/agentbbs/fileweb.go b/cmd/agentbbs/fileweb.go index 93acafb..674e795 100644 --- a/cmd/agentbbs/fileweb.go +++ b/cmd/agentbbs/fileweb.go @@ -23,7 +23,10 @@ func (a *app) startFilesWeb() { } addr := env("AGENTBBS_FILES_WEB_ADDR", "127.0.0.1:8092") title := env("AGENTBBS_FILES_WEB_TITLE", "files."+strings.TrimPrefix(a.host, "bbs.")) - h := a.files.WebHandler(files.WebConfig{Authenticate: a.filesWebAuth, Title: title}) + // Member homepages ("sites") live on the BBS host; the ~user directory links + // each member to https:///~name alongside their public files here. + siteBase := env("AGENTBBS_FILES_WEB_SITE_BASE", "https://"+a.host) + h := a.files.WebHandler(files.WebConfig{Authenticate: a.filesWebAuth, Title: title, SiteBase: siteBase}) srv := &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 10 * time.Second} go func() { log.Info("files web listening", "addr", addr) diff --git a/docs/files.md b/docs/files.md index 2cf9a8f..baa00ba 100644 --- a/docs/files.md +++ b/docs/files.md @@ -31,28 +31,32 @@ gated on membership, *not* on the paid Founding Lifetime plan: - Operators can revoke an individual account's SFTP access (abuse response) without touching its BBS login — see the management TUI below. -## Three areas +## Two areas -When you connect you see a virtual root with three directories: +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 | -| `/site` | Your **own public area**, served on the web at `~` | read/write (yours), world-read anonymously | | `/public` | The single **shared public file area** (old-school BBS file area) | world-read; members-only write by default | -`/me` is private — there is **no** path from one member's `/me` to another's. The -two *public* surfaces are `/site` (per-member, the only thing exposed at -`~`) and the single shared `/public`. All three 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) has no route into anyone's -`/me`. +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 +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`. + +> files.\ is a **file server, not a website host**. Member homepages +> ("sites") live on the BBS at `https:///~`; the file host's `~user` +> directory links out to them but does not serve them. ## Quotas -Each member's **owned storage** has a byte quota (default **1 GiB**, set by -`AGENTBBS_FILES_QUOTA_MB`). The gauge sums your private `/me` **and** your public -`/site`; writes to either that would exceed the quota fail. Operators can set a +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. @@ -90,7 +94,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,sites,public}` | +| `AGENTBBS_DATA` | `./data` | storage lives under `/files/{users,public}` (a member's public files are `users//public`) | ## Provisioning members from a public key (for external services) @@ -124,10 +128,11 @@ has no route into anyone's `/me`. | URL | Serves | Auth | |---|---|---| -| `/` | A **directory of members' `~user` sites**, plus a sign-in link | none | -| `/~[/path]` | That member's public `/site` — browse + download | none | +| `/` | 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 | +| `/~` | Redirects to `/~/public/` | none | | `/public[/path]` | The shared public area — browse + download | none | -| `/?path=…`, `/upload`, … | The authenticated manager: your `/me`, `/site`, `/public` | webmail login | +| `/?path=…`, `/upload`, … | The authenticated manager: your `/me` + the shared `/public` | webmail login | Clean URLs map **1:1 to the SFTP paths**, so share links just work: @@ -135,8 +140,8 @@ 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:/site/ - -> https://files.profullstack.com/~chovy/index.html +scp index.html files@files.profullstack.com:/me/public/ + -> https://files.profullstack.com/~chovy/public/index.html ``` Directories render a read-only browse listing; files stream with a content type diff --git a/internal/files/backend.go b/internal/files/backend.go index 803755c..c853873 100644 --- a/internal/files/backend.go +++ b/internal/files/backend.go @@ -80,7 +80,7 @@ func New(st FilesStore, cfg Config) (*Service, error) { cfg.DefaultQuota = DefaultQuota } cfg.Root = filepath.Clean(cfg.Root) - for _, d := range []string{cfg.Root, filepath.Join(cfg.Root, "users"), filepath.Join(cfg.Root, "sites"), filepath.Join(cfg.Root, "public")} { + for _, d := range []string{cfg.Root, filepath.Join(cfg.Root, "users"), filepath.Join(cfg.Root, "public")} { if err := os.MkdirAll(d, 0o755); err != nil { return nil, err } @@ -96,10 +96,11 @@ func (s *Service) privRoot(user string) string { // pubRoot is the absolute shared public-area directory. func (s *Service) pubRoot() string { return filepath.Join(s.cfg.Root, "public") } -// siteRoot is the absolute per-user public ("site") directory for a member, -// served unauthenticated at ~ on the web file host. -func (s *Service) siteRoot(user string) string { - return filepath.Join(s.cfg.Root, "sites", user) +// 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") } // ensureWorkspace creates a member's private workspace if absent. @@ -107,25 +108,14 @@ func (s *Service) ensureWorkspace(user string) error { return os.MkdirAll(s.privRoot(user), 0o700) } -// ensureSite creates a member's public site directory if absent. It is -// world-readable (0o755) because the web host serves it anonymously at ~. -func (s *Service) ensureSite(user string) error { - return os.MkdirAll(s.siteRoot(user), 0o755) -} - -// ownedUsage sums the member-owned areas — their private /me workspace plus -// their public /site — for the quota gauge. The shared /public area is -// operator-managed and is not metered per user. -func (s *Service) ownedUsage(user string) (int64, error) { - priv, err := dirSize(s.privRoot(user)) - if err != nil { - return 0, err +// 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 } - site, err := dirSize(s.siteRoot(user)) - if err != nil { - return 0, err - } - return priv + site, nil + return os.MkdirAll(s.publicHome(user), 0o755) } // quotaFor returns the effective quota (bytes) for a user: their per-user @@ -184,40 +174,39 @@ func dirSize(root string) (int64, error) { return total, err } -// SitePeer is a member with a published public site, for the anonymous ~user -// directory index on the web file host. -type SitePeer struct { - Name string - Bytes int64 +// Member is a member listed in the anonymous ~user directory at the root of the +// web file host. PublicBytes is the size of their ~/public folder. +type Member struct { + Name string + PublicBytes int64 } -// PublicSites lists members who have published anything to their public /site, -// sorted by name — the source for the anonymous ~user directory at the root of -// the web file host. Members with an empty site are omitted. -func (s *Service) PublicSites() ([]SitePeer, error) { +// Members lists every (non-banned) account, sorted by name — the source for the +// anonymous ~user directory at the root of the web file host. Every member is +// listed (it is a real directory), each with a link to their site and to their +// public files; PublicBytes shows how much they have published. +func (s *Service) Members() ([]Member, error) { users, err := s.st.ListUsers(10000) if err != nil { return nil, err } - out := make([]SitePeer, 0, len(users)) + out := make([]Member, 0, len(users)) for _, u := range users { if u.Banned { continue } - n, err := dirSize(s.siteRoot(u.Name)) - if err != nil || n == 0 { - continue - } - out = append(out, SitePeer{Name: u.Name, Bytes: n}) + n, _ := dirSize(s.publicHome(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 }) return out, nil } // AnonRoot resolves the on-disk root for an anonymous, read-only browse target: -// the shared public area (name == "") or a member's public site (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. +// 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. func (s *Service) AnonRoot(name string) (root string, ok bool, err error) { if name == "" { return s.pubRoot(), true, nil @@ -229,13 +218,13 @@ func (s *Service) AnonRoot(name string) (root string, ok bool, err error) { if !found || u.Banned { return "", false, nil } - // Materialize the (idempotent) site dir so ~name is browsable the moment the - // account exists — before the member's first SFTP/web session creates it. - // Without this, joining onto a missing root trips the escape guard. - if err := s.ensureSite(u.Name); err != 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 { return "", false, err } - return s.siteRoot(u.Name), true, nil + return s.publicHome(u.Name), true, nil } // SafeJoin exposes the area-confinement join (lexical + symlink-escape guard) @@ -256,10 +245,10 @@ func (u Usage) Free() int64 { return u.Quota - u.Bytes } -// Usage computes a member's owned-storage usage (private /me + public /site) -// against their quota. +// Usage computes a member's private-workspace usage (all of /me, which includes +// their ~/public folder) against their quota. func (s *Service) Usage(u store.User) (Usage, error) { - used, err := s.ownedUsage(u.Name) + used, err := dirSize(s.privRoot(u.Name)) if err != nil { return Usage{}, err } diff --git a/internal/files/e2e_test.go b/internal/files/e2e_test.go index 81847b5..ba263c5 100644 --- a/internal/files/e2e_test.go +++ b/internal/files/e2e_test.go @@ -41,8 +41,8 @@ func TestE2E_RootListing(t *testing.T) { names = append(names, fi.Name()) } sort.Strings(names) - if len(names) != 3 || names[0] != "me" || names[1] != "public" || names[2] != "site" { - t.Errorf("root listing = %v, want [me public site]", names) + if len(names) != 2 || names[0] != "me" || names[1] != "public" { + t.Errorf("root listing = %v, want [me public]", names) } } diff --git a/internal/files/files_test.go b/internal/files/files_test.go index 3b32dc4..580c3db 100644 --- a/internal/files/files_test.go +++ b/internal/files/files_test.go @@ -169,20 +169,18 @@ func TestUsage(t *testing.T) { } } -func TestUsageCountsSite(t *testing.T) { +func TestUsageCountsPublicHome(t *testing.T) { svc, _, u := newTestService(t) - if err := svc.ensureWorkspace(u.Name); err != nil { + if err := svc.ensurePublicHome(u.Name); err != nil { t.Fatal(err) } - if err := svc.ensureSite(u.Name); err != nil { - t.Fatal(err) - } - // 512 bytes private (/me) + 256 bytes public site (/site) both count toward - // the member's owned-usage gauge; the shared /public area does not. + // 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 := 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.siteRoot(u.Name), "b.txt"), []byte(strings.Repeat("y", 256)), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(svc.publicHome(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 { @@ -193,7 +191,7 @@ func TestUsageCountsSite(t *testing.T) { t.Fatal(err) } if usage.Bytes != 768 { - t.Errorf("usage = %d, want 768 (512 /me + 256 /site, shared /public excluded)", usage.Bytes) + t.Errorf("usage = %d, want 768 (512 /me + 256 /me/public, shared /public excluded)", usage.Bytes) } } diff --git a/internal/files/fs.go b/internal/files/fs.go index 2ee5906..fde360f 100644 --- a/internal/files/fs.go +++ b/internal/files/fs.go @@ -16,17 +16,16 @@ import ( ) // 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. const ( - areaMe = "me" // private, per-user workspace - areaSite = "site" // the member's own public root (served at ~) - areaPublic = "public" // the single shared public area + areaMe = "me" + areaPublic = "public" ) -// metered reports whether writes to an area count against the member's quota. -// The member's own areas (/me and /site) are metered; the shared /public is -// operator-managed and unmetered. -func metered(area string) bool { return area == areaMe || area == areaSite } - // 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") @@ -43,13 +42,12 @@ type session struct { } func (s *Service) newSession(u store.User) (*session, error) { - if err := s.ensureWorkspace(u.Name); err != nil { + // 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 { return nil, err } - if err := s.ensureSite(u.Name); err != nil { - return nil, err - } - used, err := s.ownedUsage(u.Name) + used, err := dirSize(s.privRoot(u.Name)) if err != nil { return nil, err } @@ -84,10 +82,6 @@ func (s *session) resolve(p string) (resolved, error) { switch seg[0] { case areaMe: areaRoot, area, writable = s.svc.privRoot(s.user.Name), areaMe, true - case areaSite: - // The member's own public root: they read/write it, the world reads it - // anonymously at ~. - areaRoot, area, writable = s.svc.siteRoot(s.user.Name), areaSite, true case areaPublic: areaRoot, area, writable = s.svc.pubRoot(), areaPublic, s.pubWrite default: @@ -165,7 +159,7 @@ func (s *session) entries(vpath string) ([]Entry, error) { return nil, err } if res.root { - return []Entry{{Name: areaMe, IsDir: true}, {Name: areaSite, IsDir: true}, {Name: areaPublic, IsDir: true}}, nil + return []Entry{{Name: areaMe, IsDir: true}, {Name: areaPublic, IsDir: true}}, nil } des, err := os.ReadDir(res.real) if err != nil { @@ -309,8 +303,8 @@ func (s *session) webSave(vpath string, r io.Reader) (int64, error) { if err != nil { return 0, err } - limit := int64(-1) // shared public area is operator-managed, unmetered - if metered(res.area) { + limit := int64(-1) // public area is operator-managed, unmetered + if res.area == areaMe { if limit = s.quota - (s.used.Load() - existing); limit < 0 { limit = 0 } @@ -324,7 +318,7 @@ func (s *session) webSave(vpath string, r io.Reader) (int64, error) { if cerr != nil { return 0, cerr } - if metered(res.area) { + if res.area == areaMe { s.used.Add(n - existing) } return n, nil @@ -386,9 +380,9 @@ func (s *session) Filewrite(r *sftp.Request) (io.WriterAt, error) { if err != nil { return nil, sftpErr(err) } - // The shared public area is operator-managed (no per-user quota); the - // member's own areas (/me and /site) are metered. - if !metered(res.area) { + // The public area is operator-managed (no per-user quota); the private + // workspace is metered. + if res.area != areaMe { return f, nil } return "aWriter{f: f, sess: s, tracked: startSize}, nil @@ -456,7 +450,7 @@ func (s *session) Filelist(r *sftp.Request) (sftp.ListerAt, error) { switch r.Method { case "List": if res.root { - return listerAt{dirInfo(areaMe), dirInfo(areaSite), dirInfo(areaPublic)}, nil + return listerAt{dirInfo(areaMe), dirInfo(areaPublic)}, nil } entries, err := os.ReadDir(res.real) if err != nil { diff --git a/internal/files/web.go b/internal/files/web.go index 2eea08a..c0ea1a6 100644 --- a/internal/files/web.go +++ b/internal/files/web.go @@ -27,6 +27,10 @@ type WebConfig struct { Authenticate func(user, pass string) (store.User, bool, error) // Title is shown in the page header (e.g. "files.profullstack.com"). Title string + // SiteBase is the base URL of the member homepages ("sites"), e.g. + // "https://bbs.profullstack.com". The ~user directory links each member to + // SiteBase + "/~" + name. Empty disables the site link. + SiteBase string // SessionTTL defaults to 12h. SessionTTL time.Duration } @@ -330,14 +334,23 @@ func (h *webSrv) renderLogin(w http.ResponseWriter, errMsg string) { _ = loginTmpl.Execute(w, loginData{Title: h.cfg.Title, Err: errMsg}) } -// renderIndex serves the public landing page: a directory of members' ~user -// sites (each linking to their public /site), plus a link to the shared /public -// area and a sign-in link. No authentication required. +// renderIndex serves the public landing page: a directory of every member, +// each linked to their site (homepage on the BBS) and their public files folder +// (~name/public here), plus a link to the shared /public area and a sign-in +// link. No authentication required. func (h *webSrv) renderIndex(w http.ResponseWriter, errMsg string) { data := indexData{Title: h.cfg.Title, Err: errMsg} - if peers, err := h.svc.PublicSites(); err == nil { - for _, p := range peers { - data.Peers = append(data.Peers, indexPeer{Name: p.Name, URL: "/~" + p.Name + "/", UsedH: humanSize(p.Bytes)}) + if members, err := h.svc.Members(); err == nil { + for _, m := range members { + peer := indexPeer{ + Name: m.Name, + FilesURL: "/~" + m.Name + "/public/", + UsedH: humanSize(m.PublicBytes), + } + if h.cfg.SiteBase != "" { + peer.SiteURL = strings.TrimRight(h.cfg.SiteBase, "/") + "/~" + m.Name + } + data.Peers = append(data.Peers, peer) } } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -345,10 +358,10 @@ func (h *webSrv) renderIndex(w http.ResponseWriter, errMsg string) { } // handleAnon serves the unauthenticated, read-only public surface: the shared -// /public area and each member's public site at /~. 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 — there is no -// path to a member's private /me or above the area root. +// /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. 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) @@ -358,18 +371,32 @@ func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) { 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 + // exposed; the rest of the member's home stays private. seg := strings.SplitN(strings.TrimPrefix(upath, "/~"), "/", 2) name = seg[0] if name == "" { http.Redirect(w, r, "/", http.StatusSeeOther) return } + after := "" if len(seg) == 2 { - rel = "/" + seg[1] + after = "/" + seg[1] } - prefix, heading = "/~"+name, "~"+name + if after == "" || after == "/" { + http.Redirect(w, r, "/~"+name+"/public/", http.StatusSeeOther) + return + } + if after != "/public" && !strings.HasPrefix(after, "/public/") { + http.NotFound(w, r) + return + } + rel = strings.TrimPrefix(after, "/public") + prefix, heading = "/~"+name+"/public", "~"+name+"/public" default: http.NotFound(w, r) return @@ -386,10 +413,9 @@ func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) { } fi, err := os.Stat(real) if err != nil { - // A known member whose site dir hasn't been created yet (it is created - // lazily on their first files session) renders as an empty listing — not - // a 404 — so ~name is reachable as soon as the account exists. A missing - // sub-path still 404s. + // A known member with an empty/not-yet-created ~/public folder renders as + // an empty listing — not a 404 — so ~name/public is reachable the moment + // the account exists. A missing sub-path still 404s. if os.IsNotExist(err) && path.Clean("/"+strings.TrimPrefix(rel, "/")) == "/" { h.renderAnonDir(w, prefix, heading, rel, real) return @@ -562,13 +588,14 @@ var listTmpl = template.Must(template.New("list").Parse(`{{end}} {{if not .Entries}}(empty){{end}} -

Your /site files are public at ~{{.User}}. Also reachable over SFTP with your SSH key: sftp files@{{.Title}}.

+

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

`)) type indexPeer struct { - Name string - URL string - UsedH string + Name string + FilesURL string // /~name/public/ + SiteURL string // https://bbs.../~name (empty if no SiteBase) + UsedH string } type indexData struct { @@ -596,22 +623,25 @@ type anonData struct { var indexTmpl = template.Must(template.New("index").Parse(` {{.Title}}
-

{{.Title}}

+

{{.Title}}

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

Public member sites. Each links to that member's shared /site files. Sign in to manage your own files (private /me + your /site).

- -{{range .Peers}}{{end}} -{{if not .Peers}}{{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.

+
MemberSize
📂 ~{{.Name}}{{.UsedH}}
No public sites yet — sign in and add files to /site.
+{{range .Peers}} + + +{{end}} +{{if not .Peers}}{{end}}
MemberSitePublic filesSize
👤 {{.Name}}{{if .SiteURL}}site ↗{{else}}{{end}}📂 ~{{.Name}}/public{{.UsedH}}
No members yet.
-

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

+

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

`)) var anonTmpl = template.Must(template.New("anon").Parse(` {{.CurPath}} · {{.Title}}
-

{{.Title}}

+

{{.Title}}

{{.CurPath}}
-

Read-only public area.

+

Read-only public files.

{{if .UpOK}}{{end}} {{range .Entries}} diff --git a/internal/files/web_test.go b/internal/files/web_test.go index 99db026..19180aa 100644 --- a/internal/files/web_test.go +++ b/internal/files/web_test.go @@ -162,22 +162,29 @@ func TestWebAnonPublicSite(t *testing.T) { h, _ := webTestHandler(t) cookie := loginCookie(t, h) - // alice publishes to her own public /site and to the shared /public. - uploadTo(t, h, cookie, "/site", "hello.txt", "from alice site") + // 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") - // The unauthenticated root lists ~alice (a public-site directory), not a wall. + // The unauthenticated root lists every member with a link to ~alice/public. rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) - if body := rr.Body.String(); !strings.Contains(body, "~alice") { - t.Fatalf("index missing ~alice directory entry: %.300s", body) + if body := rr.Body.String(); !strings.Contains(body, "/~alice/public/") { + t.Fatalf("index missing ~alice/public link: %.300s", body) } - // Anonymous (no cookie) can download a member's published site file. + // Anonymous (no cookie) can download a member's published file. rr = httptest.NewRecorder() - h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/hello.txt", nil)) - if got, _ := io.ReadAll(rr.Body); string(got) != "from alice site" { - t.Fatalf("~alice file: got %q", got) + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/public/hello.txt", nil)) + if got, _ := io.ReadAll(rr.Body); string(got) != "from alice" { + t.Fatalf("~alice/public file: got %q", got) + } + + // Bare ~alice redirects to ~alice/public/. + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice", nil)) + if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/~alice/public/" { + 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. @@ -189,16 +196,16 @@ func TestWebAnonPublicSite(t *testing.T) { // Anonymous directory browse renders a listing. rr = httptest.NewRecorder() - h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/", nil)) + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/public/", nil)) if body := rr.Body.String(); !strings.Contains(body, "hello.txt") { - t.Fatalf("~alice browse missing file: %.300s", body) + t.Fatalf("~alice/public browse missing file: %.300s", body) } } func TestWebAnonMemberSiteEmptyNot404(t *testing.T) { - // A registered member who has not published anything yet (no site dir on - // disk) is reachable at ~name 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 (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. svc, st, _ := newTestService(t) if _, err := st.EnsureUser("bob", "member", "SHA256:bobkey"); err != nil { t.Fatal(err) @@ -206,22 +213,29 @@ func TestWebAnonMemberSiteEmptyNot404(t *testing.T) { h := svc.WebHandler(WebConfig{Title: "files.test"}) rr := httptest.NewRecorder() - h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/", nil)) + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/public/", nil)) if rr.Code != http.StatusOK { - t.Fatalf("~bob (member, empty site): want 200, got %d", rr.Code) + t.Fatalf("~bob/public (member, empty): want 200, got %d", rr.Code) } if !strings.Contains(rr.Body.String(), "(empty)") { - t.Fatalf("~bob should render an empty listing: %.200s", rr.Body.String()) + t.Fatalf("~bob/public should render an empty listing: %.200s", rr.Body.String()) } rr = httptest.NewRecorder() - h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/nope.txt", nil)) + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/public/nope.txt", nil)) if rr.Code != http.StatusNotFound { - t.Fatalf("~bob/nope.txt: want 404, got %d", rr.Code) + t.Fatalf("~bob/public/nope.txt: want 404, got %d", rr.Code) + } + + // Anything under ~bob that is not /public is not exposed. + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/secret", nil)) + if rr.Code != http.StatusNotFound { + t.Fatalf("~bob/secret: want 404, got %d", rr.Code) } rr = httptest.NewRecorder() - h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~nobody/", nil)) + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~nobody/public/", nil)) if rr.Code != http.StatusNotFound { t.Fatalf("~nobody (unknown): want 404, got %d", rr.Code) } @@ -232,13 +246,15 @@ func TestWebAnonCannotEscape(t *testing.T) { cookie := loginCookie(t, h) uploadTo(t, h, cookie, "/me", "secret.txt", "private") - // There is no anonymous route into /me, and traversal out of a public area - // must not reach the private workspace. + // Only ~name/public is exposed; traversal out of a public area must not reach + // the private home or anything above it. for _, p := range []string{ - "/~alice/../../users/alice/secret.txt", + "/~alice/public/../../secret.txt", + "/~alice/public/../../../users/alice/secret.txt", "/public/../users/alice/secret.txt", - "/~alice/..%2f..%2fusers%2falice%2fsecret.txt", - "/~ghost/anything", // unknown member + "/~alice/public/..%2f..%2fsecret.txt", + "/~alice/secret.txt", // not under /public + "/~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 7f27683..d66c7dc 100755 --- a/setup.sh +++ b/setup.sh @@ -700,19 +700,21 @@ FILES_SITE=" ${FILES_DOMAIN} { encode zstd gzip - # Everything is served by the agentbbs file-manager on loopback. It serves, - # all from the same virtual storage as SFTP: - # / public directory of members' ~user sites (no auth) - # /~[/...] a member's public /site — anon read-only browse + files + # A pure file server (NOT a website host — member homepages live on the BBS + # 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, their /site, and /public + # (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}:/site/ - # -> https://${FILES_DOMAIN}/~/index.html - # The anon surface has no route to a member's private /me (see internal/files - # web tests); it is structurally read-only and area-confined. + # scp index.html files@${FILES_DOMAIN}:/me/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. reverse_proxy http://${FILES_WEB_ADDR} } "
NameSizeModified
⬑ ..