From e5e8e755531b508b37a1fcfdd3adda1027419083 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 25 Jun 2026 18:19:08 +0000 Subject: [PATCH 1/3] feat(files): provision-user CLI + anonymous public HTTP serving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets external services (the TronBrowser extension store) host files on files.profullstack.com without the interactive `ssh join@` onboarding. - `agentbbs provision-user --name --pubkey ""`: registers a member from an SSH *public* key (account = handle + key fingerprint). Reuses SanitizeUsername (same rules as join@) + EnsureUser; Files/SFTP access is free for members, so the account can immediately `scp … files@host:/public/extensions//`. JSON output; refuses on key/ handle collision. New auth.FingerprintAuthorizedKey() parses an authorized_keys line to the same SHA256 fp as a live session key (tested). - setup.sh: the files. Caddy site now serves the shared /public area as unauthenticated, read-only static files (handle_path /public/*), so .crx/.zip download links work for anyone — mapping 1:1 to the SFTP path. Non-/public paths still hit the auth'd web file manager. - docs/files.md updated. Note: not compiled here — repo go.mod requires go 1.26 and this sandbox has 1.22.2; changes pass gofmt parse/format checks. Reuses existing store/auth APIs. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/main.go | 4 ++ cmd/agentbbs/provision.go | 90 +++++++++++++++++++++++++++++++++ docs/files.md | 39 ++++++++++++++ internal/auth/auth.go | 13 +++++ internal/auth/provision_test.go | 50 ++++++++++++++++++ setup.sh | 14 +++++ 6 files changed, 210 insertions(+) create mode 100644 cmd/agentbbs/provision.go create mode 100644 internal/auth/provision_test.go diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index f5e2346..8a6a574 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -162,6 +162,10 @@ func main() { notifyCreds(st, os.Args[2:]) return } + if len(os.Args) > 1 && os.Args[1] == "provision-user" { + provisionUser(st, os.Args[2:]) + return + } if len(os.Args) > 1 && os.Args[1] == "qrypt-issuer-keygen" { qryptIssuerKeygen() return diff --git a/cmd/agentbbs/provision.go b/cmd/agentbbs/provision.go new file mode 100644 index 0000000..0f580b3 --- /dev/null +++ b/cmd/agentbbs/provision.go @@ -0,0 +1,90 @@ +package main + +// provision-user registers a member account from an SSH *public* key supplied +// out of band — the bridge that lets external services (e.g. the TronBrowser +// extension store, files.profullstack.com) onboard a publisher without the +// interactive `ssh join@` flow. An AgentBBS account is just (handle + key +// fingerprint), so this fingerprints the key and EnsureUser's it; Files/SFTP +// access is free for every member, so the account can immediately: +// +// scp dist.crx files@:/public/extensions// +// +// Mirrors the other operator subcommands (grant-pod, mint-token, …). Output is +// JSON on stdout so a caller can parse it; errors go to stderr with exit 1. +// +// agentbbs provision-user --name acme --pubkey "ssh-ed25519 AAAA… acme@dev" +// agentbbs provision-user --name acme --pubkey-file ./id_ed25519.pub + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "strings" + + "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/store" +) + +func provisionUser(st store.Store, args []string) { + fs := flag.NewFlagSet("provision-user", flag.ExitOnError) + name := fs.String("name", "", "member handle to create (a-z0-9-, 3-20, not reserved)") + pubkey := fs.String("pubkey", "", "SSH public key (authorized_keys line)") + pubkeyFile := fs.String("pubkey-file", "", "read the SSH public key from this file") + kind := fs.String("kind", string(auth.Member), "account kind: member | agent") + fs.Parse(args) + + // Normalize with the same rules the hub uses for self-service joins, so + // store-provisioned handles are indistinguishable from join@ ones. + handle, ok := auth.SanitizeUsername(*name) + if !ok { + fail("invalid --name: needs 3-20 chars of a-z, 0-9, dash and must not be reserved") + } + + keyText := strings.TrimSpace(*pubkey) + if keyText == "" && *pubkeyFile != "" { + b, err := os.ReadFile(*pubkeyFile) + if err != nil { + fail("read --pubkey-file: " + err.Error()) + } + keyText = strings.TrimSpace(string(b)) + } + if keyText == "" { + fail("provide --pubkey or --pubkey-file") + } + + fp, err := auth.FingerprintAuthorizedKey(keyText) + if err != nil { + fail("not a valid SSH public key: " + err.Error()) + } + + // If this key already belongs to someone, report that account rather than + // silently creating a second handle for the same key. + if existing, ok, err := st.UserByFingerprint(fp); err != nil { + fail("lookup by fingerprint: " + err.Error()) + } else if ok && existing.Name != handle { + fail(fmt.Sprintf("this key already belongs to member %q (fp %s)", existing.Name, fp)) + } + + u, err := st.EnsureUser(handle, *kind, fp) + if err != nil { + if errors.Is(err, store.ErrKeyMismatch) { + fail(fmt.Sprintf("handle %q is already registered with a different key", handle)) + } + fail("ensure user: " + err.Error()) + } + + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{ + "ok": true, + "name": u.Name, + "kind": u.Kind, + "fingerprint": fp, + "store_id": u.ID, + }) +} + +func fail(msg string) { + fmt.Fprintln(os.Stderr, "provision-user: "+msg) + os.Exit(1) +} diff --git a/docs/files.md b/docs/files.md index 7c18629..c9d348b 100644 --- a/docs/files.md +++ b/docs/files.md @@ -88,6 +88,45 @@ content-blind. | `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) | | `AGENTBBS_DATA` | `./data` | storage lives under `/files/{users,public}` | +## Provisioning members from a public key (for external services) + +Normally members onboard interactively (`ssh join@`). External services that +want to grant a user file storage without that flow — e.g. the TronBrowser +extension store letting a publisher upload bundles — can register an account +directly from an SSH **public** key (an account is just *handle + key +fingerprint*): + +```bash +agentbbs provision-user --name acme --pubkey "ssh-ed25519 AAAA… acme@dev" +# or: --pubkey-file ./id_ed25519.pub +``` + +It normalizes the handle with the same rules as `join@` (`SanitizeUsername`), +fingerprints the key, and `EnsureUser`s the member; Files/SFTP access is then +available immediately (free for all members). Output is JSON (`{ok, name, +fingerprint, store_id}`); it refuses if the key already belongs to another +member or the handle is taken by a different key. The publisher can then: + +```bash +scp dist.crx files@files.profullstack.com:/public/extensions/acme/ +``` + +## Public files over HTTP (anonymous, read-only) + +The web file manager at `files.` requires a login even to download, which +is wrong for *shared* artifacts (a `.crx` download link must work for anyone). +So the `files.` Caddy site (generated by `setup.sh`) serves the shared +`/public` area as **unauthenticated, read-only** static files, mapping 1:1 to +the SFTP path: + +``` +scp x files@files.profullstack.com:/public/extensions/acme/x + -> https://files.profullstack.com/public/extensions/acme/x +``` + +Everything outside `/public/*` still falls through to the authenticated web +manager (private `/me` browsing). + ## Implementation - `internal/files` — a fully virtual Go SFTP server (`github.com/pkg/sftp` + diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f119bb9..544384e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -204,3 +204,16 @@ func Fingerprint(key ssh.PublicKey) string { } return gossh.FingerprintSHA256(key) } + +// FingerprintAuthorizedKey parses an OpenSSH authorized_keys line (e.g. +// "ssh-ed25519 AAAA… comment") and returns its SHA256 fingerprint — the same +// value Fingerprint produces for a live session key. Used to provision a member +// from a public key supplied out of band (e.g. the extension store). Any +// trailing options/comment are ignored. +func FingerprintAuthorizedKey(authorizedKey string) (string, error) { + key, _, _, _, err := gossh.ParseAuthorizedKey([]byte(strings.TrimSpace(authorizedKey))) + if err != nil { + return "", err + } + return gossh.FingerprintSHA256(key), nil +} diff --git a/internal/auth/provision_test.go b/internal/auth/provision_test.go new file mode 100644 index 0000000..777c40b --- /dev/null +++ b/internal/auth/provision_test.go @@ -0,0 +1,50 @@ +package auth + +import ( + "crypto/ed25519" + "crypto/rand" + "strings" + "testing" + + gossh "golang.org/x/crypto/ssh" +) + +func TestFingerprintAuthorizedKey(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + sshPub, err := gossh.NewPublicKey(pub) + if err != nil { + t.Fatal(err) + } + authLine := string(gossh.MarshalAuthorizedKey(sshPub)) // "ssh-ed25519 AAAA…\n" + want := gossh.FingerprintSHA256(sshPub) + + // Bare line. + got, err := FingerprintAuthorizedKey(authLine) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != want { + t.Fatalf("fingerprint = %q, want %q", got, want) + } + + // With a trailing comment + surrounding whitespace. + got2, err := FingerprintAuthorizedKey(" " + strings.TrimRight(authLine, "\n") + " acme@dev ") + if err != nil { + t.Fatalf("unexpected error with comment: %v", err) + } + if got2 != want { + t.Fatalf("fingerprint with comment = %q, want %q", got2, want) + } +} + +func TestFingerprintAuthorizedKeyRejectsGarbage(t *testing.T) { + if _, err := FingerprintAuthorizedKey("not a key"); err == nil { + t.Fatal("expected error for non-key input") + } + if _, err := FingerprintAuthorizedKey(""); err == nil { + t.Fatal("expected error for empty input") + } +} diff --git a/setup.sh b/setup.sh index 2f969e7..81c1c5d 100755 --- a/setup.sh +++ b/setup.sh @@ -699,6 +699,20 @@ fi FILES_SITE=" ${FILES_DOMAIN} { encode zstd gzip + + # Public file area — unauthenticated, read-only HTTP for the shared /public + # directory, so download links (e.g. extension .crx/.zip) work for everyone. + # Maps 1:1 to the SFTP path: a member who runs + # scp dist.crx files@${FILES_DOMAIN}:/public/extensions/acme/ + # gets the URL https://${FILES_DOMAIN}/public/extensions/acme/dist.crx . + # Everything else falls through to the auth'd web file manager below. + handle_path /public/* { + root * ${DATA_DIR}/files/public + header Cache-Control \"public, max-age=300\" + file_server + } + + # Member web file manager (webmail-password login; /me + /public browsing). reverse_proxy http://${FILES_WEB_ADDR} } " From d1615ac81726b1842b229ed806bd90a2505b35b1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 25 Jun 2026 18:36:23 +0000 Subject: [PATCH 2/3] fix(vet): redundant newline in wish.Println premium-flow messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `go test ./...` / `go vet ./...` fail on `wish.Println(… "…\n")` — Println already appends a newline. Pre-existing on main (its CI is red for the same two lines); surfaced here. Switched both to `wish.Print` with an explicit trailing "\n\n" so output bytes are unchanged and vet is satisfied. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 8a6a574..de57789 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -968,7 +968,7 @@ func (a *app) offerPremium(s ssh.Session, in *bufio.Reader, u *store.User) { wish.Print(s, "\n Become a Founding member now? Type \"yes\" for a payment address [no]: ") line, err := readLine(s, in) if err != nil || !isYes(line) { - wish.Println(s, "\n No problem — you're a free member. Want it later? Re-run: ssh join@"+a.host+"\n") + wish.Print(s, "\n No problem — you're a free member. Want it later? Re-run: ssh join@"+a.host+"\n\n") return } @@ -978,7 +978,7 @@ func (a *app) offerPremium(s ssh.Session, in *bufio.Reader, u *store.User) { if err != nil { log.Error("create premium charge", "err", err) } - wish.Println(s, "\n Payment is temporarily unavailable — please try again shortly.\n") + wish.Print(s, "\n Payment is temporarily unavailable — please try again shortly.\n\n") return } // Remember the payment id so a later connect can confirm settlement. From e478da905f08f7c4114501ffb19a6fbddd244ee7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 26 Jun 2026 00:49:38 +0000 Subject: [PATCH 3/3] files: add per-user public /site + anonymous web surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a third storage area, /site — each member's own public root, served unauthenticated on the web at ~ alongside the shared /public. Web file host (files.) is no longer a login wall: - GET / -> directory of members' ~user sites (+ sign-in link) - GET /~/... -> anon read-only browse + clean file URLs of /site - GET /public/... -> anon read-only browse + clean file URLs of shared area (fixes bare /public requiring login: the old Caddy `handle_path /public/*` never matched /public) Login is now optional and gates only private /me + writes. The anon surface has no route into anyone's /me and safeJoin rejects traversal. Usage gauge now sums the member-owned areas (/me + /site) instead of /me alone; shared /public stays operator-managed and unmetered. Caddy: route all of files. to the Go manager. Docs + tests updated (anon download/browse, traversal confinement, /site metering). Co-Authored-By: Claude Opus 4.8 --- docs/files.md | 58 ++++++---- internal/files/backend.go | 87 ++++++++++++++- internal/files/e2e_test.go | 4 +- internal/files/files_test.go | 28 +++++ internal/files/fs.go | 35 ++++-- internal/files/web.go | 201 ++++++++++++++++++++++++++++++++++- internal/files/web_test.go | 102 ++++++++++++++++++ setup.sh | 24 ++--- 8 files changed, 486 insertions(+), 53 deletions(-) diff --git a/docs/files.md b/docs/files.md index c9d348b..9b7c45a 100644 --- a/docs/files.md +++ b/docs/files.md @@ -31,26 +31,30 @@ 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. -## Two areas +## Three areas -When you connect you see a virtual root with two directories: +When you connect you see a virtual root with three 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 | -There is **no** path from one member's `/me` to another's — the only sharing -surface is the one public area (PRD §9.3, amended). Both areas are confined: a -path that tries to escape its root (`../`, an absolute path, or a planted -symlink) is rejected. +`/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`. ## Quotas -Each private workspace has a byte quota (default **1 GiB**, set by -`AGENTBBS_FILES_QUOTA_MB`). Writes that would exceed it fail. Operators can set a -per-user override in the management TUI. The public area is operator-managed and -not metered per user. +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 +per-user override in the management TUI. The shared `/public` area is +operator-managed and **not** metered per user. ## In-BBS browser @@ -86,7 +90,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,public}` | +| `AGENTBBS_DATA` | `./data` | storage lives under `/files/{users,sites,public}` | ## Provisioning members from a public key (for external services) @@ -111,21 +115,33 @@ member or the handle is taken by a different key. The publisher can then: scp dist.crx files@files.profullstack.com:/public/extensions/acme/ ``` -## Public files over HTTP (anonymous, read-only) +## The web file host (`files.`) -The web file manager at `files.` requires a login even to download, which -is wrong for *shared* artifacts (a `.crx` download link must work for anyone). -So the `files.` Caddy site (generated by `setup.sh`) serves the shared -`/public` area as **unauthenticated, read-only** static files, mapping 1:1 to -the SFTP path: +The whole site is served by the Go file manager (`internal/files/web.go`), which +Caddy reverse-proxies. **Login is optional** — it gates only your private `/me` +and writes. The public surface is anonymous, read-only, and area-confined; it +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 | +| `/public[/path]` | The shared public area — browse + download | none | +| `/?path=…`, `/upload`, … | The authenticated manager: your `/me`, `/site`, `/public` | webmail login | + +Clean URLs map **1:1 to the SFTP paths**, so share links just work: ``` -scp x files@files.profullstack.com:/public/extensions/acme/x - -> https://files.profullstack.com/public/extensions/acme/x +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 ``` -Everything outside `/public/*` still falls through to the authenticated web -manager (private `/me` browsing). +Directories render a read-only browse listing; files stream with a content type +and a short (`max-age=300`) cache. Sign in (top-right link, webmail password) to +manage your own files. ## Implementation diff --git a/internal/files/backend.go b/internal/files/backend.go index afb2089..ca4c486 100644 --- a/internal/files/backend.go +++ b/internal/files/backend.go @@ -26,6 +26,7 @@ import ( "io/fs" "os" "path/filepath" + "sort" "sync" "sync/atomic" "time" @@ -79,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, "public")} { + for _, d := range []string{cfg.Root, filepath.Join(cfg.Root, "users"), filepath.Join(cfg.Root, "sites"), filepath.Join(cfg.Root, "public")} { if err := os.MkdirAll(d, 0o755); err != nil { return nil, err } @@ -95,11 +96,38 @@ 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) +} + // ensureWorkspace creates a member's private workspace if absent. 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 + } + site, err := dirSize(s.siteRoot(user)) + if err != nil { + return 0, err + } + return priv + site, nil +} + // quotaFor returns the effective quota (bytes) for a user: their per-user // override if set, else the server default. func (s *Service) quotaFor(userID int64) int64 { @@ -156,6 +184,58 @@ 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 +} + +// 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) { + users, err := s.st.ListUsers(10000) + if err != nil { + return nil, err + } + out := make([]SitePeer, 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}) + } + 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. +func (s *Service) AnonRoot(name string) (root string, ok bool, err error) { + if name == "" { + return s.pubRoot(), true, nil + } + u, found, err := s.st.UserByName(name) + if err != nil { + return "", false, err + } + if !found || u.Banned { + return "", false, nil + } + return s.siteRoot(u.Name), true, nil +} + +// SafeJoin exposes the area-confinement join (lexical + symlink-escape guard) +// for the web host's anonymous read-only surface. +func (s *Service) SafeJoin(root, rel string) (string, error) { return safeJoin(root, rel) } + // Usage is a member's workspace usage snapshot. type Usage struct { Bytes int64 @@ -170,9 +250,10 @@ func (u Usage) Free() int64 { return u.Quota - u.Bytes } -// Usage computes a member's private-workspace usage against their quota. +// Usage computes a member's owned-storage usage (private /me + public /site) +// against their quota. 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 { return Usage{}, err } diff --git a/internal/files/e2e_test.go b/internal/files/e2e_test.go index ba263c5..81847b5 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) != 2 || names[0] != "me" || names[1] != "public" { - t.Errorf("root listing = %v, want [me public]", names) + if len(names) != 3 || names[0] != "me" || names[1] != "public" || names[2] != "site" { + t.Errorf("root listing = %v, want [me public site]", names) } } diff --git a/internal/files/files_test.go b/internal/files/files_test.go index c250538..3b32dc4 100644 --- a/internal/files/files_test.go +++ b/internal/files/files_test.go @@ -169,6 +169,34 @@ func TestUsage(t *testing.T) { } } +func TestUsageCountsSite(t *testing.T) { + svc, _, u := newTestService(t) + if err := svc.ensureWorkspace(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. + 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 { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(svc.pubRoot(), "shared.txt"), []byte(strings.Repeat("z", 9999)), 0o644); err != nil { + t.Fatal(err) + } + usage, err := svc.Usage(u) + if err != nil { + t.Fatal(err) + } + if usage.Bytes != 768 { + t.Errorf("usage = %d, want 768 (512 /me + 256 /site, shared /public excluded)", usage.Bytes) + } +} + func TestRevokeBlocksAndQuotaOverride(t *testing.T) { svc, st, u := newTestService(t) if err := st.SetFilesQuota(u.ID, 4096); err != nil { diff --git a/internal/files/fs.go b/internal/files/fs.go index 9aefe32..2ee5906 100644 --- a/internal/files/fs.go +++ b/internal/files/fs.go @@ -17,10 +17,16 @@ import ( // area names exposed at the virtual root. const ( - areaMe = "me" - areaPublic = "public" + areaMe = "me" // private, per-user workspace + areaSite = "site" // the member's own public root (served at ~) + areaPublic = "public" // the single shared public area ) +// 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") @@ -40,7 +46,10 @@ func (s *Service) newSession(u store.User) (*session, error) { if err := s.ensureWorkspace(u.Name); err != nil { return nil, err } - used, err := dirSize(s.privRoot(u.Name)) + if err := s.ensureSite(u.Name); err != nil { + return nil, err + } + used, err := s.ownedUsage(u.Name) if err != nil { return nil, err } @@ -75,6 +84,10 @@ 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: @@ -152,7 +165,7 @@ func (s *session) entries(vpath string) ([]Entry, error) { return nil, err } if res.root { - return []Entry{{Name: areaMe, IsDir: true}, {Name: areaPublic, IsDir: true}}, nil + return []Entry{{Name: areaMe, IsDir: true}, {Name: areaSite, IsDir: true}, {Name: areaPublic, IsDir: true}}, nil } des, err := os.ReadDir(res.real) if err != nil { @@ -296,8 +309,8 @@ func (s *session) webSave(vpath string, r io.Reader) (int64, error) { if err != nil { return 0, err } - limit := int64(-1) // public area is operator-managed, unmetered - if res.area == areaMe { + limit := int64(-1) // shared public area is operator-managed, unmetered + if metered(res.area) { if limit = s.quota - (s.used.Load() - existing); limit < 0 { limit = 0 } @@ -311,7 +324,7 @@ func (s *session) webSave(vpath string, r io.Reader) (int64, error) { if cerr != nil { return 0, cerr } - if res.area == areaMe { + if metered(res.area) { s.used.Add(n - existing) } return n, nil @@ -373,9 +386,9 @@ func (s *session) Filewrite(r *sftp.Request) (io.WriterAt, error) { if err != nil { return nil, sftpErr(err) } - // The public area is operator-managed (no per-user quota); the private - // workspace is metered. - if res.area != areaMe { + // 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) { return f, nil } return "aWriter{f: f, sess: s, tracked: startSize}, nil @@ -443,7 +456,7 @@ func (s *session) Filelist(r *sftp.Request) (sftp.ListerAt, error) { switch r.Method { case "List": if res.root { - return listerAt{dirInfo(areaMe), dirInfo(areaPublic)}, nil + return listerAt{dirInfo(areaMe), dirInfo(areaSite), 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 317982c..612f9c8 100644 --- a/internal/files/web.go +++ b/internal/files/web.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "path" + "sort" "strconv" "strings" "sync" @@ -57,13 +58,15 @@ func (s *Service) WebHandler(cfg WebConfig) http.Handler { } h := &webSrv{svc: s, cfg: cfg, sess: map[string]webSession{}} mux := http.NewServeMux() - mux.HandleFunc("/", h.handleRoot) + mux.HandleFunc("/", h.handleRoot) // index (~user dir) + /~name browsing + authed manager mux.HandleFunc("/login", h.handleLogin) mux.HandleFunc("/logout", h.handleLogout) mux.HandleFunc("/download", h.handleDownload) mux.HandleFunc("/upload", h.handleUpload) mux.HandleFunc("/mkdir", h.handleMkdir) 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")) }) return mux } @@ -121,9 +124,16 @@ func randHex(n int) string { // --- handlers --------------------------------------------------------------- func (h *webSrv) handleRoot(w http.ResponseWriter, r *http.Request) { + // Anonymous per-member public browsing: /~[/...]. No session required. + if strings.HasPrefix(r.URL.Path, "/~") { + h.handleAnon(w, r) + return + } name, ok := h.lookup(r) if !ok { - h.renderLogin(w, "") + // Not signed in: the root is a public directory of members' ~user sites, + // with a sign-in link — not a login wall. + h.renderIndex(w, "") return } vpath := cleanVPath(r.URL.Query().Get("path")) @@ -171,7 +181,12 @@ func (h *webSrv) handleRoot(w http.ResponseWriter, r *http.Request) { func (h *webSrv) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.Redirect(w, r, "/", http.StatusSeeOther) + // GET /login renders the sign-in form (the root is the public index). + if _, ok := h.lookup(r); ok { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + h.renderLogin(w, "") return } _ = r.ParseForm() @@ -315,6 +330,128 @@ 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. +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)}) + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = indexTmpl.Execute(w, data) +} + +// 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. +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) + return + } + upath := path.Clean("/" + strings.TrimPrefix(r.URL.Path, "/")) + var name, rel, prefix, heading string + switch { + case upath == "/public" || strings.HasPrefix(upath, "/public/"): + name, rel, prefix, heading = "", strings.TrimPrefix(upath, "/public"), "/public", "/public" + case strings.HasPrefix(upath, "/~"): + seg := strings.SplitN(strings.TrimPrefix(upath, "/~"), "/", 2) + name = seg[0] + if name == "" { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if len(seg) == 2 { + rel = "/" + seg[1] + } + prefix, heading = "/~"+name, "~"+name + default: + http.NotFound(w, r) + return + } + root, ok, err := h.svc.AnonRoot(name) + if err != nil || !ok { + http.NotFound(w, r) + return + } + real, err := h.svc.SafeJoin(root, strings.TrimPrefix(rel, "/")) + if err != nil { + http.NotFound(w, r) // escaped the area root + return + } + fi, err := os.Stat(real) + if err != nil { + http.NotFound(w, r) + return + } + if fi.IsDir() { + h.renderAnonDir(w, prefix, heading, rel, real) + return + } + f, err := os.Open(real) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + w.Header().Set("Cache-Control", "public, max-age=300") + http.ServeContent(w, r, fi.Name(), fi.ModTime(), f) +} + +// renderAnonDir renders a read-only listing of a public directory. prefix is the +// area URL base ("/public" or "/~name"); rel is the path within it; real is the +// on-disk directory (already confined by handleAnon). +func (h *webSrv) renderAnonDir(w http.ResponseWriter, prefix, heading, rel, real string) { + des, err := os.ReadDir(real) + if err != nil { + http.Error(w, "cannot list files", http.StatusInternalServerError) + return + } + rel = path.Clean("/" + strings.TrimPrefix(rel, "/")) + data := anonData{Title: h.cfg.Title, CurPath: heading} + if rel != "/" { + data.CurPath = heading + rel + parent := path.Dir(rel) + up := prefix + if parent != "/" { + up = prefix + parent + } + data.UpURL, data.UpOK = up+"/", true + } + for _, de := range des { + fi, err := de.Info() + if err != nil { + continue + } + url := prefix + path.Join(rel, de.Name()) + if de.IsDir() { + url += "/" + } + data.Entries = append(data.Entries, anonEntry{ + Name: de.Name(), IsDir: de.IsDir(), SizeH: humanSize(fi.Size()), + URL: url, Mod: fi.ModTime().Format("2006-01-02 15:04"), + }) + } + sortEntries(data.Entries) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = anonTmpl.Execute(w, data) +} + +// sortEntries orders a listing directories-first, then by name. +func sortEntries(es []anonEntry) { + sort.Slice(es, func(i, j int) bool { + if es[i].IsDir != es[j].IsDir { + return es[i].IsDir + } + return es[i].Name < es[j].Name + }) +} + // --- view models + templates ------------------------------------------------ type loginData struct { @@ -416,7 +553,63 @@ var listTmpl = template.Must(template.New("list").Parse(`{{end}} {{if not .Entries}}(empty){{end}} -

Also reachable over SFTP: sftp files@files.profullstack.com (with your SSH key).

+

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

+`)) + +type indexPeer struct { + Name string + URL string + UsedH string +} + +type indexData struct { + Title string + Peers []indexPeer + Err string +} + +type anonEntry struct { + Name string + IsDir bool + SizeH string + URL string + Mod string +} + +type anonData struct { + Title string + CurPath string + UpURL string + UpOK bool + Entries []anonEntry +} + +var indexTmpl = template.Must(template.New("index").Parse(` +{{.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}} +
MemberSize
📂 ~{{.Name}}{{.UsedH}}
No public sites yet — sign in and add files to /site.
+

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

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

{{.Title}}

+
{{.CurPath}}
+

Read-only public area.

+ +{{if .UpOK}}{{end}} +{{range .Entries}} + +{{end}} +{{if not .Entries}}{{end}} +
NameSizeModified
⬑ ..
{{if .IsDir}}📁 {{.Name}}/{{else}}📄 {{.Name}}{{end}}{{if not .IsDir}}{{.SizeH}}{{end}}{{.Mod}}
(empty)
`)) // --- path helpers ----------------------------------------------------------- diff --git a/internal/files/web_test.go b/internal/files/web_test.go index 8203b55..cf4757a 100644 --- a/internal/files/web_test.go +++ b/internal/files/web_test.go @@ -124,6 +124,108 @@ func TestWebRoundTrip(t *testing.T) { } } +// loginCookie logs alice in and returns her session cookie. +func loginCookie(t *testing.T, h http.Handler) *http.Cookie { + t.Helper() + form := url.Values{"user": {"alice"}, "pass": {"secret"}} + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + h.ServeHTTP(rr, req) + cs := rr.Result().Cookies() + if len(cs) == 0 { + t.Fatal("login did not set a cookie") + } + return cs[0] +} + +// uploadTo uploads body to dir as the holder of cookie. +func uploadTo(t *testing.T, h http.Handler, cookie *http.Cookie, dir, name, body string) { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _ = mw.WriteField("dir", dir) + fw, _ := mw.CreateFormFile("file", name) + _, _ = fw.Write([]byte(body)) + _ = mw.Close() + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/upload", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.AddCookie(cookie) + h.ServeHTTP(rr, req) + if rr.Code != http.StatusSeeOther { + t.Fatalf("upload to %s: want redirect, got %d (%s)", dir, rr.Code, rr.Body.String()) + } +} + +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") + uploadTo(t, h, cookie, "/public", "shared.txt", "shared file") + + // The unauthenticated root lists ~alice (a public-site directory), not a wall. + 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) + } + + // Anonymous (no cookie) can download a member's published site 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) + } + + // 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. + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/", nil)) + if body := rr.Body.String(); !strings.Contains(body, "hello.txt") { + t.Fatalf("~alice browse missing file: %.300s", body) + } +} + +func TestWebAnonCannotEscape(t *testing.T) { + h, _ := webTestHandler(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. + for _, p := range []string{ + "/~alice/../../users/alice/secret.txt", + "/public/../users/alice/secret.txt", + "/~alice/..%2f..%2fusers%2falice%2fsecret.txt", + "/~ghost/anything", // unknown member + } { + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil)) + if body, _ := io.ReadAll(rr.Body); strings.Contains(string(body), "private") { + t.Fatalf("anon path %q leaked private content", p) + } + if rr.Code == http.StatusOK && strings.Contains(rr.Body.String(), "secret.txt") { + t.Fatalf("anon path %q exposed /me", p) + } + } + + // And the authed download API still rejects /me without a session. + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/download?path=/me/secret.txt", nil)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("anon /me download: want 401, got %d", rr.Code) + } +} + func TestWebPublicReadOnlyByDefault(t *testing.T) { h, _ := webTestHandler(t) // Log in. diff --git a/setup.sh b/setup.sh index 81c1c5d..7f27683 100755 --- a/setup.sh +++ b/setup.sh @@ -700,19 +700,19 @@ FILES_SITE=" ${FILES_DOMAIN} { encode zstd gzip - # Public file area — unauthenticated, read-only HTTP for the shared /public - # directory, so download links (e.g. extension .crx/.zip) work for everyone. - # Maps 1:1 to the SFTP path: a member who runs + # 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 + # /public[/...] the shared public area — anon read-only browse + files + # (signed in) the member's private /me, their /site, and /public + # Clean URLs map 1:1 to the SFTP paths, so share links just work: # scp dist.crx files@${FILES_DOMAIN}:/public/extensions/acme/ - # gets the URL https://${FILES_DOMAIN}/public/extensions/acme/dist.crx . - # Everything else falls through to the auth'd web file manager below. - handle_path /public/* { - root * ${DATA_DIR}/files/public - header Cache-Control \"public, max-age=300\" - file_server - } - - # Member web file manager (webmail-password login; /me + /public browsing). + # -> 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. reverse_proxy http://${FILES_WEB_ADDR} } "