mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
files: /me and /public are two separate per-user areas
Per feedback: /me is PRIVATE and the public folder must be its own top-level area, not nested under /me. - A member now has two sibling areas over SFTP: /me (private, <root>/files/users/<name>) and /public (their own public files, <root>/files/public/<name>), served anonymously at ~<name>/public. - Drop the global shared /public web route and the /me/public nesting. The anon surface only exposes ~name/public; /me has no anon route. - Both owned areas count toward the quota gauge. - Index publish hint, docs, and setup.sh updated to scp :/public/. files.<host> stays a file server; member sites remain on the BBS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
a2d4817a8e
commit
f2bcb7e063
7 changed files with 135 additions and 135 deletions
|
|
@ -38,15 +38,14 @@ When you connect you see a virtual root with two directories:
|
||||||
| Path | What it is | Access |
|
| Path | What it is | Access |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/me` | Your **private** per-user workspace | read/write, quota-limited |
|
| `/me` | Your **private** per-user workspace | read/write, quota-limited |
|
||||||
| `/public` | The single **shared public file area** (old-school BBS file area) | world-read; members-only write by default |
|
| `/public` | Your **own public files** area, published at `~<name>/public` | read/write (yours); world-read anonymously |
|
||||||
|
|
||||||
Your `/me` home has one special subfolder: **`/me/public`**. Anything you put
|
These are two **separate** areas — `/public` is a sibling of `/me`, **not** a
|
||||||
there is published on the web at **`~<name>/public`** (the unix `~/public`
|
folder inside it. `/me` stays fully private; anything you put in `/public` is
|
||||||
convention) — that, and only that, is the per-member public surface. The rest of
|
published on the web at **`~<name>/public`**. Both count toward your quota. Both
|
||||||
`/me` stays private; there is **no** path from one member's `/me` to another's.
|
areas are confined: a path that tries to escape its root (`../`, an absolute
|
||||||
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
|
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`.
|
(below) only ever exposes `~name/public`, never your private `/me`.
|
||||||
|
|
||||||
> files.\<host\> is a **file server, not a website host**. Member homepages
|
> 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`
|
> ("sites") live on the BBS at `https://<host>/~<name>`; the file host's `~user`
|
||||||
|
|
@ -54,11 +53,10 @@ path, or a planted symlink) is rejected, and the unauthenticated web surface
|
||||||
|
|
||||||
## Quotas
|
## Quotas
|
||||||
|
|
||||||
Each private workspace has a byte quota (default **1 GiB**, set by
|
Each member has a byte quota (default **1 GiB**, set by
|
||||||
`AGENTBBS_FILES_QUOTA_MB`). The gauge measures all of `/me` — including your
|
`AGENTBBS_FILES_QUOTA_MB`). The gauge sums both of your areas — private `/me`
|
||||||
`/me/public` folder — and writes that would exceed it fail. Operators can set a
|
**and** your public `/public` — and writes that would exceed it fail. Operators
|
||||||
per-user override in the management TUI. The shared `/public` area is
|
can set a per-user override in the management TUI.
|
||||||
operator-managed and **not** metered per user.
|
|
||||||
|
|
||||||
## In-BBS browser
|
## In-BBS browser
|
||||||
|
|
||||||
|
|
@ -94,7 +92,7 @@ content-blind.
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `AGENTBBS_FILES` | `1` | enable the SFTP subsystem + Files plugin (`0` disables) |
|
| `AGENTBBS_FILES` | `1` | enable the SFTP subsystem + Files plugin (`0` disables) |
|
||||||
| `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) |
|
| `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) |
|
||||||
| `AGENTBBS_DATA` | `./data` | storage lives under `<data>/files/{users,public}` (a member's public files are `users/<name>/public`) |
|
| `AGENTBBS_DATA` | `./data` | storage lives under `<data>/files/{users,public}` — private `/me` is `users/<name>`, public `/public` is `public/<name>` |
|
||||||
|
|
||||||
## Provisioning members from a public key (for external services)
|
## Provisioning members from a public key (for external services)
|
||||||
|
|
||||||
|
|
@ -129,18 +127,14 @@ has no route into anyone's `/me`.
|
||||||
| URL | Serves | Auth |
|
| URL | Serves | Auth |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/` | A **directory of all members** — each linked to their BBS **site** and their **public files** | 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>/public[/path]` | That member's own public files (`/public`) — browse + download | none |
|
||||||
| `/~<name>` | Redirects to `/~<name>/public/` | none |
|
| `/~<name>` | Redirects to `/~<name>/public/` | none |
|
||||||
| `/public[/path]` | The shared public area — browse + download | none |
|
| `/?path=…`, `/upload`, … | The authenticated manager: your `/me` and your `/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:
|
Clean URLs map **1:1 to the SFTP paths**, so share links just work:
|
||||||
|
|
||||||
```
|
```
|
||||||
scp dist.crx files@files.profullstack.com:/public/extensions/acme/
|
scp index.html files@files.profullstack.com:/public/
|
||||||
-> https://files.profullstack.com/public/extensions/acme/dist.crx
|
|
||||||
|
|
||||||
scp index.html files@files.profullstack.com:/me/public/
|
|
||||||
-> https://files.profullstack.com/~chovy/public/index.html
|
-> https://files.profullstack.com/~chovy/public/index.html
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,14 +93,16 @@ func (s *Service) privRoot(user string) string {
|
||||||
return filepath.Join(s.cfg.Root, "users", user)
|
return filepath.Join(s.cfg.Root, "users", user)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pubRoot is the absolute shared public-area directory.
|
// pubRoot is the parent of the per-user public areas (<root>/public). Each
|
||||||
|
// member's own public files live in a subdirectory keyed by handle; this parent
|
||||||
|
// is what the operator moderation pane lists.
|
||||||
func (s *Service) pubRoot() string { return filepath.Join(s.cfg.Root, "public") }
|
func (s *Service) pubRoot() string { return filepath.Join(s.cfg.Root, "public") }
|
||||||
|
|
||||||
// publicHome is the member's own public file folder: the public/ subdirectory of
|
// userPub is a member's own public file area: <root>/public/<name>. It is a
|
||||||
// their private home. It is exposed anonymously on the web at ~<name>/public
|
// top-level area in its own right (sibling to the private /me, NOT nested under
|
||||||
// (the unix ~/public convention) and metered as part of /me.
|
// it) and is served anonymously on the web at ~<name>/public.
|
||||||
func (s *Service) publicHome(user string) string {
|
func (s *Service) userPub(user string) string {
|
||||||
return filepath.Join(s.privRoot(user), "public")
|
return filepath.Join(s.pubRoot(), user)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureWorkspace creates a member's private workspace if absent.
|
// ensureWorkspace creates a member's private workspace if absent.
|
||||||
|
|
@ -108,14 +110,24 @@ func (s *Service) ensureWorkspace(user string) error {
|
||||||
return os.MkdirAll(s.privRoot(user), 0o700)
|
return os.MkdirAll(s.privRoot(user), 0o700)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensurePublicHome creates a member's ~/public folder if absent (and the home
|
// ensureUserPub creates a member's public area if absent. It is world-readable
|
||||||
// above it). The home stays private (0o700); the public/ subdir is the only
|
// (0o755) because the web host serves it anonymously at ~<name>/public.
|
||||||
// part the web host exposes anonymously, and the Go server reads it directly.
|
func (s *Service) ensureUserPub(user string) error {
|
||||||
func (s *Service) ensurePublicHome(user string) error {
|
return os.MkdirAll(s.userPub(user), 0o755)
|
||||||
if err := s.ensureWorkspace(user); err != nil {
|
}
|
||||||
return err
|
|
||||||
|
// ownedUsage sums a member's two owned areas — their private /me and their
|
||||||
|
// public /public — for the quota gauge.
|
||||||
|
func (s *Service) ownedUsage(user string) (int64, error) {
|
||||||
|
priv, err := dirSize(s.privRoot(user))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
}
|
}
|
||||||
return os.MkdirAll(s.publicHome(user), 0o755)
|
pub, err := dirSize(s.userPub(user))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return priv + pub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// quotaFor returns the effective quota (bytes) for a user: their per-user
|
// quotaFor returns the effective quota (bytes) for a user: their per-user
|
||||||
|
|
@ -195,7 +207,7 @@ func (s *Service) Members() ([]Member, error) {
|
||||||
if u.Banned {
|
if u.Banned {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
n, _ := dirSize(s.publicHome(u.Name))
|
n, _ := dirSize(s.userPub(u.Name))
|
||||||
out = append(out, Member{Name: u.Name, PublicBytes: n})
|
out = append(out, Member{Name: u.Name, PublicBytes: n})
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
|
@ -203,13 +215,13 @@ func (s *Service) Members() ([]Member, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnonRoot resolves the on-disk root for an anonymous, read-only browse target:
|
// AnonRoot resolves the on-disk root for an anonymous, read-only browse target:
|
||||||
// the shared public area (name == "") or a member's public files folder, i.e.
|
// a member's own public area /public (name = the ~handle), served at
|
||||||
// ~/public (name = the ~handle). ok is false when the named member does not
|
// ~<name>/public. ok is false when the named member does not exist or is banned.
|
||||||
// exist or is banned. The returned root is a confinement boundary — callers must
|
// The returned root is a confinement boundary — callers must safeJoin onto it,
|
||||||
// safeJoin onto it, and it never exposes the rest of the member's private home.
|
// and it never exposes the member's private /me.
|
||||||
func (s *Service) AnonRoot(name string) (root string, ok bool, err error) {
|
func (s *Service) AnonRoot(name string) (root string, ok bool, err error) {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return s.pubRoot(), true, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
u, found, err := s.st.UserByName(name)
|
u, found, err := s.st.UserByName(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -218,13 +230,13 @@ func (s *Service) AnonRoot(name string) (root string, ok bool, err error) {
|
||||||
if !found || u.Banned {
|
if !found || u.Banned {
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
// Materialize the (idempotent) ~/public folder so ~name/public is browsable
|
// Materialize the (idempotent) public area so ~name/public is browsable the
|
||||||
// the moment the account exists. Without this, joining onto a missing root
|
// moment the account exists. Without this, joining onto a missing root trips
|
||||||
// trips the escape guard.
|
// the escape guard.
|
||||||
if err := s.ensurePublicHome(u.Name); err != nil {
|
if err := s.ensureUserPub(u.Name); err != nil {
|
||||||
return "", false, err
|
return "", false, err
|
||||||
}
|
}
|
||||||
return s.publicHome(u.Name), true, nil
|
return s.userPub(u.Name), true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SafeJoin exposes the area-confinement join (lexical + symlink-escape guard)
|
// SafeJoin exposes the area-confinement join (lexical + symlink-escape guard)
|
||||||
|
|
@ -245,10 +257,10 @@ func (u Usage) Free() int64 {
|
||||||
return u.Quota - u.Bytes
|
return u.Quota - u.Bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage computes a member's private-workspace usage (all of /me, which includes
|
// Usage computes a member's owned-storage usage (private /me + public /public)
|
||||||
// their ~/public folder) against their quota.
|
// against their quota.
|
||||||
func (s *Service) Usage(u store.User) (Usage, error) {
|
func (s *Service) Usage(u store.User) (Usage, error) {
|
||||||
used, err := dirSize(s.privRoot(u.Name))
|
used, err := s.ownedUsage(u.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Usage{}, err
|
return Usage{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,28 +103,21 @@ func TestSymlinkEscapeBlocked(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicWriteACL(t *testing.T) {
|
func TestOwnPublicWritable(t *testing.T) {
|
||||||
svc, st, u := newTestService(t)
|
svc, _, u := newTestService(t)
|
||||||
|
|
||||||
// Default: members may write to the public area.
|
// A member's /public is their own area (served at ~name/public) — they can
|
||||||
|
// always write to it, and it resolves under <root>/public/<name>.
|
||||||
sess, _ := svc.newSession(u)
|
sess, _ := svc.newSession(u)
|
||||||
r := sftp.NewRequest("Mkdir", "/public/uploads")
|
if err := sess.Filecmd(sftp.NewRequest("Mkdir", "/public/uploads")); err != nil {
|
||||||
if err := sess.Filecmd(r); err != nil {
|
t.Fatalf("own /public mkdir should succeed: %v", err)
|
||||||
t.Fatalf("public mkdir should succeed by default: %v", err)
|
|
||||||
}
|
}
|
||||||
|
res, err := sess.resolve("/public/uploads")
|
||||||
// Turn public write off → writes denied, reads still fine.
|
if err != nil || !res.writable {
|
||||||
if err := svc.SetPublicWrite(false); err != nil {
|
t.Fatalf("own /public should resolve writable: %+v err=%v", res, err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
_ = st
|
if want := svc.userPub(u.Name); !within(want, res.real) && res.real != want {
|
||||||
sess2, _ := svc.newSession(u)
|
t.Errorf("/public resolved to %q, want under %q", res.real, want)
|
||||||
if err := sess2.Filecmd(sftp.NewRequest("Mkdir", "/public/more")); err != sftp.ErrSSHFxPermissionDenied {
|
|
||||||
t.Errorf("public write should be denied when off, got %v", err)
|
|
||||||
}
|
|
||||||
res, err := sess2.resolve("/public/uploads")
|
|
||||||
if err != nil || res.writable {
|
|
||||||
t.Errorf("public should resolve read-only when write is off: %+v err=%v", res, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,21 +162,27 @@ func TestUsage(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUsageCountsPublicHome(t *testing.T) {
|
func TestUsageCountsOwnedAreas(t *testing.T) {
|
||||||
svc, _, u := newTestService(t)
|
svc, _, u := newTestService(t)
|
||||||
if err := svc.ensurePublicHome(u.Name); err != nil {
|
if err := svc.ensureWorkspace(u.Name); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// A member's ~/public folder lives inside their private /me home, so both the
|
if err := svc.ensureUserPub(u.Name); err != nil {
|
||||||
// top-level private file and the public file count toward the quota gauge;
|
t.Fatal(err)
|
||||||
// the shared /public area does not.
|
}
|
||||||
|
// 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.
|
||||||
if err := os.WriteFile(filepath.Join(svc.privRoot(u.Name), "a.txt"), []byte(strings.Repeat("x", 512)), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(svc.privRoot(u.Name), "a.txt"), []byte(strings.Repeat("x", 512)), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(svc.publicHome(u.Name), "b.txt"), []byte(strings.Repeat("y", 256)), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(svc.userPub(u.Name), "b.txt"), []byte(strings.Repeat("y", 256)), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(svc.pubRoot(), "shared.txt"), []byte(strings.Repeat("z", 9999)), 0o644); err != nil {
|
if err := os.MkdirAll(svc.userPub("someoneelse"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(svc.userPub("someoneelse"), "x.txt"), []byte(strings.Repeat("z", 9999)), 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
usage, err := svc.Usage(u)
|
usage, err := svc.Usage(u)
|
||||||
|
|
@ -191,7 +190,7 @@ func TestUsageCountsPublicHome(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if usage.Bytes != 768 {
|
if usage.Bytes != 768 {
|
||||||
t.Errorf("usage = %d, want 768 (512 /me + 256 /me/public, shared /public excluded)", usage.Bytes)
|
t.Errorf("usage = %d, want 768 (512 /me + 256 /public, other members excluded)", usage.Bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,19 @@ import (
|
||||||
"github.com/profullstack/agentbbs/internal/store"
|
"github.com/profullstack/agentbbs/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// area names exposed at the virtual root.
|
// area names exposed at the virtual root. A member has two separate top-level
|
||||||
//
|
// areas: /me (private) and /public (their own public files, served anonymously
|
||||||
// A member's home is /me (private). Its public/ subdirectory is exposed
|
// on the web at ~<name>/public). /public is a sibling of /me, never nested
|
||||||
// anonymously on the web at ~<name>/public — the unix ~/public convention — so
|
// inside it — /me stays fully private.
|
||||||
// "publish a file" just means dropping it in /me/public. The single shared
|
|
||||||
// /public area is the old-school BBS file area.
|
|
||||||
const (
|
const (
|
||||||
areaMe = "me"
|
areaMe = "me"
|
||||||
areaPublic = "public"
|
areaPublic = "public"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// metered reports whether writes to an area count against the member's quota.
|
||||||
|
// Both of a member's owned areas (/me and their /public) are metered.
|
||||||
|
func metered(area string) bool { return area == areaMe || area == areaPublic }
|
||||||
|
|
||||||
// errEscape is returned when a resolved path would leave its area root. It maps
|
// 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.
|
// to an SFTP permission-denied; it must never reach the client as a real path.
|
||||||
var errEscape = errors.New("files: path escapes its area")
|
var errEscape = errors.New("files: path escapes its area")
|
||||||
|
|
@ -34,24 +36,26 @@ var errEscape = errors.New("files: path escapes its area")
|
||||||
// connection. It implements the pkg/sftp request handlers and enforces area
|
// connection. It implements the pkg/sftp request handlers and enforces area
|
||||||
// confinement, the public-area ACL, and the per-user quota.
|
// confinement, the public-area ACL, and the per-user quota.
|
||||||
type session struct {
|
type session struct {
|
||||||
svc *Service
|
svc *Service
|
||||||
user store.User
|
user store.User
|
||||||
pubWrite bool
|
quota int64
|
||||||
quota int64
|
used atomic.Int64 // live owned-storage usage (/me + /public), for quota checks
|
||||||
used atomic.Int64 // live private-workspace usage, for quota checks
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) newSession(u store.User) (*session, error) {
|
func (s *Service) newSession(u store.User) (*session, error) {
|
||||||
// Ensure both the private home and its public/ subfolder exist, so writing to
|
// Ensure both of the member's areas exist: their private /me and their own
|
||||||
// /me/public (which surfaces at ~name/public) just works.
|
// public /public (a sibling, not a subfolder of /me).
|
||||||
if err := s.ensurePublicHome(u.Name); err != nil {
|
if err := s.ensureWorkspace(u.Name); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
used, err := dirSize(s.privRoot(u.Name))
|
if err := s.ensureUserPub(u.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
used, err := s.ownedUsage(u.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
sess := &session{svc: s, user: u, pubWrite: s.publicWritable(), quota: s.quotaFor(u.ID)}
|
sess := &session{svc: s, user: u, quota: s.quotaFor(u.ID)}
|
||||||
sess.used.Store(used)
|
sess.used.Store(used)
|
||||||
return sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +87,9 @@ func (s *session) resolve(p string) (resolved, error) {
|
||||||
case areaMe:
|
case areaMe:
|
||||||
areaRoot, area, writable = s.svc.privRoot(s.user.Name), areaMe, true
|
areaRoot, area, writable = s.svc.privRoot(s.user.Name), areaMe, true
|
||||||
case areaPublic:
|
case areaPublic:
|
||||||
areaRoot, area, writable = s.svc.pubRoot(), areaPublic, s.pubWrite
|
// The member's own public area (served anonymously at ~<name>/public);
|
||||||
|
// they read/write it.
|
||||||
|
areaRoot, area, writable = s.svc.userPub(s.user.Name), areaPublic, true
|
||||||
default:
|
default:
|
||||||
return resolved{}, os.ErrNotExist
|
return resolved{}, os.ErrNotExist
|
||||||
}
|
}
|
||||||
|
|
@ -303,8 +309,8 @@ func (s *session) webSave(vpath string, r io.Reader) (int64, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
limit := int64(-1) // public area is operator-managed, unmetered
|
limit := int64(-1)
|
||||||
if res.area == areaMe {
|
if metered(res.area) {
|
||||||
if limit = s.quota - (s.used.Load() - existing); limit < 0 {
|
if limit = s.quota - (s.used.Load() - existing); limit < 0 {
|
||||||
limit = 0
|
limit = 0
|
||||||
}
|
}
|
||||||
|
|
@ -318,7 +324,7 @@ func (s *session) webSave(vpath string, r io.Reader) (int64, error) {
|
||||||
if cerr != nil {
|
if cerr != nil {
|
||||||
return 0, cerr
|
return 0, cerr
|
||||||
}
|
}
|
||||||
if res.area == areaMe {
|
if metered(res.area) {
|
||||||
s.used.Add(n - existing)
|
s.used.Add(n - existing)
|
||||||
}
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
|
|
@ -380,9 +386,8 @@ func (s *session) Filewrite(r *sftp.Request) (io.WriterAt, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, sftpErr(err)
|
return nil, sftpErr(err)
|
||||||
}
|
}
|
||||||
// The public area is operator-managed (no per-user quota); the private
|
// Both of the member's owned areas (/me and their /public) are metered.
|
||||||
// workspace is metered.
|
if !metered(res.area) {
|
||||||
if res.area != areaMe {
|
|
||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
return "aWriter{f: f, sess: s, tracked: startSize}, nil
|
return "aWriter{f: f, sess: s, tracked: startSize}, nil
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,6 @@ func (s *Service) WebHandler(cfg WebConfig) http.Handler {
|
||||||
mux.HandleFunc("/upload", h.handleUpload)
|
mux.HandleFunc("/upload", h.handleUpload)
|
||||||
mux.HandleFunc("/mkdir", h.handleMkdir)
|
mux.HandleFunc("/mkdir", h.handleMkdir)
|
||||||
mux.HandleFunc("/delete", h.handleDelete)
|
mux.HandleFunc("/delete", h.handleDelete)
|
||||||
mux.HandleFunc("/public", h.handleAnon) // shared public area (anon read-only)
|
|
||||||
mux.HandleFunc("/public/", h.handleAnon) // shared public area (anon read-only)
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
@ -357,11 +355,11 @@ func (h *webSrv) renderIndex(w http.ResponseWriter, errMsg string) {
|
||||||
_ = indexTmpl.Execute(w, data)
|
_ = indexTmpl.Execute(w, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAnon serves the unauthenticated, read-only public surface: the shared
|
// handleAnon serves the unauthenticated, read-only public surface: each member's
|
||||||
// /public area and each member's public files at /~<name>/public. Directories
|
// own public files at /~<name>/public. Directories render a browse listing;
|
||||||
// render a browse listing; files stream with a content type and a short cache.
|
// files stream with a content type and a short cache. It is confined to the area
|
||||||
// It is confined to the area root by the same safeJoin guard as SFTP — only a
|
// root by the same safeJoin guard as SFTP — only a member's /public area is
|
||||||
// member's ~/public subfolder is exposed, never the rest of their private /me.
|
// exposed, never their private /me.
|
||||||
func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) {
|
func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
|
@ -370,9 +368,6 @@ func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) {
|
||||||
upath := path.Clean("/" + strings.TrimPrefix(r.URL.Path, "/"))
|
upath := path.Clean("/" + strings.TrimPrefix(r.URL.Path, "/"))
|
||||||
var name, rel, prefix, heading string
|
var name, rel, prefix, heading string
|
||||||
switch {
|
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, "/~"):
|
case strings.HasPrefix(upath, "/~"):
|
||||||
// A member's own public files: /~<name>/public[/...]. The bare ~name (or
|
// 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
|
// anything not under /public) is not a file surface — only ~name/public is
|
||||||
|
|
@ -588,7 +583,7 @@ var listTmpl = template.Must(template.New("list").Parse(`<!doctype html><html><h
|
||||||
</tr>{{end}}
|
</tr>{{end}}
|
||||||
{{if not .Entries}}<tr><td colspan=4 class=muted>(empty)</td></tr>{{end}}
|
{{if not .Entries}}<tr><td colspan=4 class=muted>(empty)</td></tr>{{end}}
|
||||||
</table>
|
</table>
|
||||||
<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>
|
<p class=muted style="margin-top:20px">Your <code>/public</code> area is public at <a href="/~{{.User}}/public/">~{{.User}}/public</a>; <code>/me</code> stays private. Reachable over SFTP with your SSH key: <code>sftp files@{{.Title}}</code>.</p>
|
||||||
</div></body></html>`))
|
</div></body></html>`))
|
||||||
|
|
||||||
type indexPeer struct {
|
type indexPeer struct {
|
||||||
|
|
@ -623,7 +618,7 @@ type anonData struct {
|
||||||
var indexTmpl = template.Must(template.New("index").Parse(`<!doctype html><html><head><meta charset=utf-8>
|
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>
|
<meta name=viewport content="width=device-width,initial-scale=1"><title>{{.Title}}</title><style>` + baseCSS + `</style></head>
|
||||||
<body><div class=wrap>
|
<body><div class=wrap>
|
||||||
<div class=bar><h1>{{.Title}}</h1><div class=muted><a href="/public/">shared /public</a> · <a href="/login">Sign in</a></div></div>
|
<div class=bar><h1>{{.Title}}</h1><div class=muted><a href="/login">Sign in</a></div></div>
|
||||||
{{if .Err}}<div class="flash err">{{.Err}}</div>{{end}}
|
{{if .Err}}<div class="flash err">{{.Err}}</div>{{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>
|
<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>
|
<table><tr><th>Member</th><th>Site</th><th>Public files</th><th class=right>Size</th></tr>
|
||||||
|
|
@ -633,7 +628,7 @@ var indexTmpl = template.Must(template.New("index").Parse(`<!doctype html><html>
|
||||||
<td class=right>{{.UsedH}}</td></tr>{{end}}
|
<td class=right>{{.UsedH}}</td></tr>{{end}}
|
||||||
{{if not .Peers}}<tr><td colspan=4 class=muted>No members yet.</td></tr>{{end}}
|
{{if not .Peers}}<tr><td colspan=4 class=muted>No members yet.</td></tr>{{end}}
|
||||||
</table>
|
</table>
|
||||||
<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>
|
<p class=muted style="margin-top:20px">Publish over SFTP: <code>scp file files@{{.Title}}:/public/</code> → appears at <code>~yourname/public</code>. (<code>/me</code> stays private.)</p>
|
||||||
</div></body></html>`))
|
</div></body></html>`))
|
||||||
|
|
||||||
var anonTmpl = template.Must(template.New("anon").Parse(`<!doctype html><html><head><meta charset=utf-8>
|
var anonTmpl = template.Must(template.New("anon").Parse(`<!doctype html><html><head><meta charset=utf-8>
|
||||||
|
|
|
||||||
|
|
@ -162,9 +162,8 @@ func TestWebAnonPublicSite(t *testing.T) {
|
||||||
h, _ := webTestHandler(t)
|
h, _ := webTestHandler(t)
|
||||||
cookie := loginCookie(t, h)
|
cookie := loginCookie(t, h)
|
||||||
|
|
||||||
// alice publishes to her own public folder (/me/public) and to /public.
|
// alice publishes to her own public area (/public, a sibling of private /me).
|
||||||
uploadTo(t, h, cookie, "/me/public", "hello.txt", "from alice")
|
uploadTo(t, h, cookie, "/public", "hello.txt", "from alice")
|
||||||
uploadTo(t, h, cookie, "/public", "shared.txt", "shared file")
|
|
||||||
|
|
||||||
// The unauthenticated root lists every member with a link to ~alice/public.
|
// The unauthenticated root lists every member with a link to ~alice/public.
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|
@ -187,19 +186,19 @@ func TestWebAnonPublicSite(t *testing.T) {
|
||||||
t.Fatalf("~alice: want redirect to /~alice/public/, got %d %q", rr.Code, rr.Header().Get("Location"))
|
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.
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/public/shared.txt", nil))
|
|
||||||
if got, _ := io.ReadAll(rr.Body); string(got) != "shared file" {
|
|
||||||
t.Fatalf("/public file: got %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anonymous directory browse renders a listing.
|
// Anonymous directory browse renders a listing.
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/public/", nil))
|
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/public/", nil))
|
||||||
if body := rr.Body.String(); !strings.Contains(body, "hello.txt") {
|
if body := rr.Body.String(); !strings.Contains(body, "hello.txt") {
|
||||||
t.Fatalf("~alice/public browse missing file: %.300s", body)
|
t.Fatalf("~alice/public browse missing file: %.300s", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /me stays private: there is no anonymous route into it.
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/me/", nil))
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("~alice/me: want 404 (private), got %d", rr.Code)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebAnonMemberSiteEmptyNot404(t *testing.T) {
|
func TestWebAnonMemberSiteEmptyNot404(t *testing.T) {
|
||||||
|
|
@ -247,14 +246,13 @@ func TestWebAnonCannotEscape(t *testing.T) {
|
||||||
uploadTo(t, h, cookie, "/me", "secret.txt", "private")
|
uploadTo(t, h, cookie, "/me", "secret.txt", "private")
|
||||||
|
|
||||||
// Only ~name/public is exposed; traversal out of a public area must not reach
|
// Only ~name/public is exposed; traversal out of a public area must not reach
|
||||||
// the private home or anything above it.
|
// the private /me or anything above it.
|
||||||
for _, p := range []string{
|
for _, p := range []string{
|
||||||
"/~alice/public/../../secret.txt",
|
"/~alice/public/../../users/alice/secret.txt",
|
||||||
"/~alice/public/../../../users/alice/secret.txt",
|
"/~alice/public/../../../users/alice/secret.txt",
|
||||||
"/public/../users/alice/secret.txt",
|
"/~alice/public/..%2f..%2fusers%2falice%2fsecret.txt",
|
||||||
"/~alice/public/..%2f..%2fsecret.txt",
|
"/~alice/me/secret.txt", // /me is private — not an anon surface
|
||||||
"/~alice/secret.txt", // not under /public
|
"/~ghost/public/x", // unknown member
|
||||||
"/~ghost/public/x", // unknown member
|
|
||||||
} {
|
} {
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
|
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
|
||||||
|
|
|
||||||
17
setup.sh
17
setup.sh
|
|
@ -704,17 +704,14 @@ ${FILES_DOMAIN} {
|
||||||
# at https://${DOMAIN}/~<name>). The agentbbs file-manager on loopback serves:
|
# at https://${DOMAIN}/~<name>). The agentbbs file-manager on loopback serves:
|
||||||
# / directory of all members (links to each one's BBS site
|
# / directory of all members (links to each one's BBS site
|
||||||
# and their public files here) — no auth
|
# and their public files here) — no auth
|
||||||
# /~<name>/public[/] a member's public files folder — anon read-only browse
|
# /~<name>/public[/] a member's own public files — anon read-only browse
|
||||||
# /public[/...] the shared public area — anon read-only browse + files
|
# (signed in) the member's two areas: private /me and public /public
|
||||||
# (signed in) the member's private /me (whose public/ subdir is the
|
# A member has two SEPARATE areas: /me (private) and /public (their own public
|
||||||
# ~name/public surface) and the shared /public
|
# files, served at ~<name>/public). Clean URLs map 1:1 to the SFTP paths:
|
||||||
# Clean URLs map 1:1 to the SFTP paths, so share links just work:
|
# scp index.html files@${FILES_DOMAIN}:/public/
|
||||||
# scp dist.crx files@${FILES_DOMAIN}:/public/extensions/acme/
|
|
||||||
# -> https://${FILES_DOMAIN}/public/extensions/acme/dist.crx
|
|
||||||
# scp index.html files@${FILES_DOMAIN}:/me/public/
|
|
||||||
# -> https://${FILES_DOMAIN}/~<name>/public/index.html
|
# -> https://${FILES_DOMAIN}/~<name>/public/index.html
|
||||||
# The anon surface only ever exposes ~name/public, never the rest of a
|
# The anon surface only ever exposes ~name/public, never a member's private
|
||||||
# member's private /me (see internal/files web tests); it is read-only.
|
# /me (see internal/files web tests); it is read-only.
|
||||||
reverse_proxy http://${FILES_WEB_ADDR}
|
reverse_proxy http://${FILES_WEB_ADDR}
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue