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} } "