mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
files: per-user public at ~name/public; home = member directory
Re-model the web file host as a file server (not a website host): - Drop the misnamed /site area. A member's public files are now their /me/public subfolder (unix ~/public), served anonymously at ~<name>/public. The rest of /me stays private; only ~name/public is ever exposed. Bare /~name redirects to /~name/public/. - The root / is now a directory of ALL members, each linked to their BBS site (https://<bbs-host>/~name via WebConfig.SiteBase) AND their public files here (~name/public). No longer hides empty members. - Sites/homepages stay on the BBS — files.<host> only links to them. - Usage gauge is just /me again (which includes /me/public). setup.sh + docs updated; tests cover ~name/public browse/download, the bare-~name redirect, empty-member empty-listing, /public-only exposure, and traversal confinement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
87fb2b4a1f
commit
a2d4817a8e
9 changed files with 203 additions and 166 deletions
|
|
@ -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://<bbs-host>/~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)
|
||||
|
|
|
|||
|
|
@ -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 `~<name>` | 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
|
||||
`~<name>`) 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 **`~<name>/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.\<host\> is a **file server, not a website host**. Member homepages
|
||||
> ("sites") live on the BBS at `https://<host>/~<name>`; 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 `<data>/files/{users,sites,public}` |
|
||||
| `AGENTBBS_DATA` | `./data` | storage lives under `<data>/files/{users,public}` (a member's public files are `users/<name>/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 |
|
||||
| `/~<name>[/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 |
|
||||
| `/~<name>/public[/path]` | That member's public files (`/me/public`) — browse + download | none |
|
||||
| `/~<name>` | Redirects to `/~<name>/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
|
||||
|
|
|
|||
|
|
@ -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 ~<name> 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 ~<name>/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 ~<name>.
|
||||
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 {
|
||||
// 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
|
||||
Bytes int64
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ~<name>/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 ~<name>)
|
||||
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 ~<name>.
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 /~<name>. 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 /~<name>/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: /~<name>/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,12 +588,13 @@ var listTmpl = template.Must(template.New("list").Parse(`<!doctype html><html><h
|
|||
</tr>{{end}}
|
||||
{{if not .Entries}}<tr><td colspan=4 class=muted>(empty)</td></tr>{{end}}
|
||||
</table>
|
||||
<p class=muted style="margin-top:20px">Your <code>/site</code> files are public at <a href="/~{{.User}}/">~{{.User}}</a>. Also reachable over SFTP with your SSH key: <code>sftp files@{{.Title}}</code>.</p>
|
||||
<p class=muted style="margin-top:20px">Files in <code>/me/public</code> are public at <a href="/~{{.User}}/public/">~{{.User}}/public</a>. Reachable over SFTP with your SSH key: <code>sftp files@{{.Title}}</code>.</p>
|
||||
</div></body></html>`))
|
||||
|
||||
type indexPeer struct {
|
||||
Name string
|
||||
URL string
|
||||
FilesURL string // /~name/public/
|
||||
SiteURL string // https://bbs.../~name (empty if no SiteBase)
|
||||
UsedH string
|
||||
}
|
||||
|
||||
|
|
@ -596,22 +623,25 @@ type anonData struct {
|
|||
var indexTmpl = template.Must(template.New("index").Parse(`<!doctype html><html><head><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1"><title>{{.Title}}</title><style>` + baseCSS + `</style></head>
|
||||
<body><div class=wrap>
|
||||
<div class=bar><h1>{{.Title}}</h1><div class=muted><a href="/public/">/public</a> · <a href="/login">Sign in</a></div></div>
|
||||
<div class=bar><h1>{{.Title}}</h1><div class=muted><a href="/public/">shared /public</a> · <a href="/login">Sign in</a></div></div>
|
||||
{{if .Err}}<div class="flash err">{{.Err}}</div>{{end}}
|
||||
<p class=muted>Public member sites. Each links to that member's shared <code>/site</code> files. <a href="/login">Sign in</a> to manage your own files (private <code>/me</code> + your <code>/site</code>).</p>
|
||||
<table><tr><th>Member</th><th class=right>Size</th></tr>
|
||||
{{range .Peers}}<tr><td>📂 <a href="{{.URL}}">~{{.Name}}</a></td><td class=right>{{.UsedH}}</td></tr>{{end}}
|
||||
{{if not .Peers}}<tr><td colspan=2 class=muted>No public sites yet — sign in and add files to <code>/site</code>.</td></tr>{{end}}
|
||||
<p class=muted>Member directory. Each member has a <b>site</b> (their homepage on the BBS) and a <b>public files</b> folder here. <a href="/login">Sign in</a> to manage your own files.</p>
|
||||
<table><tr><th>Member</th><th>Site</th><th>Public files</th><th class=right>Size</th></tr>
|
||||
{{range .Peers}}<tr><td>👤 {{.Name}}</td>
|
||||
<td>{{if .SiteURL}}<a href="{{.SiteURL}}">site ↗</a>{{else}}<span class=muted>—</span>{{end}}</td>
|
||||
<td>📂 <a href="{{.FilesURL}}">~{{.Name}}/public</a></td>
|
||||
<td class=right>{{.UsedH}}</td></tr>{{end}}
|
||||
{{if not .Peers}}<tr><td colspan=4 class=muted>No members yet.</td></tr>{{end}}
|
||||
</table>
|
||||
<p class=muted style="margin-top:20px">Publish over SFTP: <code>scp file files@{{.Title}}:/site/</code> → appears at <code>~yourname</code>.</p>
|
||||
<p class=muted style="margin-top:20px">Publish over SFTP: <code>scp file files@{{.Title}}:/me/public/</code> → appears at <code>~yourname/public</code>.</p>
|
||||
</div></body></html>`))
|
||||
|
||||
var anonTmpl = template.Must(template.New("anon").Parse(`<!doctype html><html><head><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1"><title>{{.CurPath}} · {{.Title}}</title><style>` + baseCSS + `</style></head>
|
||||
<body><div class=wrap>
|
||||
<div class=bar><h1>{{.Title}}</h1><div class=muted><a href="/">all sites</a> · <a href="/login">Sign in</a></div></div>
|
||||
<div class=bar><h1>{{.Title}}</h1><div class=muted><a href="/">members</a> · <a href="/login">Sign in</a></div></div>
|
||||
<div class=crumbs><b>{{.CurPath}}</b></div>
|
||||
<p class=muted>Read-only public area.</p>
|
||||
<p class=muted>Read-only public files.</p>
|
||||
<table><tr><th>Name</th><th class=right>Size</th><th>Modified</th></tr>
|
||||
{{if .UpOK}}<tr><td><a href="{{.UpURL}}">⬑ ..</a></td><td></td><td></td></tr>{{end}}
|
||||
{{range .Entries}}<tr>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
20
setup.sh
20
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)
|
||||
# /~<name>[/...] 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}/~<name>). 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
|
||||
# /~<name>/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}/~<name>/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}/~<name>/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}
|
||||
}
|
||||
"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue