mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
Merge feat/files-store-provisioning: per-user /site + anonymous web file surface
# Conflicts: # setup.sh
This commit is contained in:
commit
192b117fc1
8 changed files with 515 additions and 43 deletions
|
|
@ -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)
|
- Operators can revoke an individual account's SFTP access (abuse response)
|
||||||
without touching its BBS login — see the management TUI below.
|
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 |
|
| 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 |
|
||||||
|
| `/site` | Your **own public area**, served on the web at `~<name>` | read/write (yours), world-read anonymously |
|
||||||
| `/public` | The single **shared public file area** (old-school BBS file area) | world-read; members-only write by default |
|
| `/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
|
`/me` is private — there is **no** path from one member's `/me` to another's. The
|
||||||
surface is the one public area (PRD §9.3, amended). Both areas are confined: a
|
two *public* surfaces are `/site` (per-member, the only thing exposed at
|
||||||
path that tries to escape its root (`../`, an absolute path, or a planted
|
`~<name>`) and the single shared `/public`. All three areas are confined: a path
|
||||||
symlink) is rejected.
|
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
|
## Quotas
|
||||||
|
|
||||||
Each private workspace has a byte quota (default **1 GiB**, set by
|
Each member's **owned storage** has a byte quota (default **1 GiB**, set by
|
||||||
`AGENTBBS_FILES_QUOTA_MB`). Writes that would exceed it fail. Operators can set a
|
`AGENTBBS_FILES_QUOTA_MB`). The gauge sums your private `/me` **and** your public
|
||||||
per-user override in the management TUI. The public area is operator-managed and
|
`/site`; writes to either that would exceed the quota fail. Operators can set a
|
||||||
not metered per user.
|
per-user override in the management TUI. The shared `/public` area is
|
||||||
|
operator-managed and **not** metered per user.
|
||||||
|
|
||||||
## In-BBS browser
|
## In-BBS browser
|
||||||
|
|
||||||
|
|
@ -86,7 +90,58 @@ 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}` |
|
| `AGENTBBS_DATA` | `./data` | storage lives under `<data>/files/{users,sites,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/
|
||||||
|
```
|
||||||
|
|
||||||
|
## The web file host (`files.<host>`)
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| `/~<name>[/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 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
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Provisioning members from a public key (for external services)
|
## Provisioning members from a public key (for external services)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -79,7 +80,7 @@ func New(st FilesStore, cfg Config) (*Service, error) {
|
||||||
cfg.DefaultQuota = DefaultQuota
|
cfg.DefaultQuota = DefaultQuota
|
||||||
}
|
}
|
||||||
cfg.Root = filepath.Clean(cfg.Root)
|
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 {
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -95,11 +96,38 @@ func (s *Service) privRoot(user string) string {
|
||||||
// pubRoot is the absolute shared public-area directory.
|
// pubRoot is the absolute shared public-area directory.
|
||||||
func (s *Service) pubRoot() string { return filepath.Join(s.cfg.Root, "public") }
|
func (s *Service) pubRoot() string { return filepath.Join(s.cfg.Root, "public") }
|
||||||
|
|
||||||
|
// siteRoot is the absolute per-user public ("site") directory for a member,
|
||||||
|
// served unauthenticated at ~<name> on the web file host.
|
||||||
|
func (s *Service) siteRoot(user string) string {
|
||||||
|
return filepath.Join(s.cfg.Root, "sites", user)
|
||||||
|
}
|
||||||
|
|
||||||
// ensureWorkspace creates a member's private workspace if absent.
|
// ensureWorkspace creates a member's private workspace if absent.
|
||||||
func (s *Service) ensureWorkspace(user string) error {
|
func (s *Service) ensureWorkspace(user string) error {
|
||||||
return os.MkdirAll(s.privRoot(user), 0o700)
|
return os.MkdirAll(s.privRoot(user), 0o700)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureSite creates a member's public site directory if absent. It is
|
||||||
|
// world-readable (0o755) because the web host serves it anonymously at ~<name>.
|
||||||
|
func (s *Service) ensureSite(user string) error {
|
||||||
|
return os.MkdirAll(s.siteRoot(user), 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownedUsage sums the member-owned areas — their private /me workspace plus
|
||||||
|
// their public /site — for the quota gauge. The shared /public area is
|
||||||
|
// operator-managed and is not metered per user.
|
||||||
|
func (s *Service) ownedUsage(user string) (int64, error) {
|
||||||
|
priv, err := dirSize(s.privRoot(user))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
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
|
// quotaFor returns the effective quota (bytes) for a user: their per-user
|
||||||
// override if set, else the server default.
|
// override if set, else the server default.
|
||||||
func (s *Service) quotaFor(userID int64) int64 {
|
func (s *Service) quotaFor(userID int64) int64 {
|
||||||
|
|
@ -156,6 +184,58 @@ func dirSize(root string) (int64, error) {
|
||||||
return total, err
|
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.
|
// Usage is a member's workspace usage snapshot.
|
||||||
type Usage struct {
|
type Usage struct {
|
||||||
Bytes int64
|
Bytes int64
|
||||||
|
|
@ -170,9 +250,10 @@ func (u Usage) Free() int64 {
|
||||||
return u.Quota - u.Bytes
|
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) {
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,8 @@ func TestE2E_RootListing(t *testing.T) {
|
||||||
names = append(names, fi.Name())
|
names = append(names, fi.Name())
|
||||||
}
|
}
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
if len(names) != 2 || names[0] != "me" || names[1] != "public" {
|
if len(names) != 3 || names[0] != "me" || names[1] != "public" || names[2] != "site" {
|
||||||
t.Errorf("root listing = %v, want [me public]", names)
|
t.Errorf("root listing = %v, want [me public site]", names)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestRevokeBlocksAndQuotaOverride(t *testing.T) {
|
||||||
svc, st, u := newTestService(t)
|
svc, st, u := newTestService(t)
|
||||||
if err := st.SetFilesQuota(u.ID, 4096); err != nil {
|
if err := st.SetFilesQuota(u.ID, 4096); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,16 @@ import (
|
||||||
|
|
||||||
// area names exposed at the virtual root.
|
// area names exposed at the virtual root.
|
||||||
const (
|
const (
|
||||||
areaMe = "me"
|
areaMe = "me" // private, per-user workspace
|
||||||
areaPublic = "public"
|
areaSite = "site" // the member's own public root (served at ~<name>)
|
||||||
|
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
|
// 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")
|
||||||
|
|
@ -40,7 +46,10 @@ func (s *Service) newSession(u store.User) (*session, error) {
|
||||||
if err := s.ensureWorkspace(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.ensureSite(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
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +84,10 @@ func (s *session) resolve(p string) (resolved, error) {
|
||||||
switch seg[0] {
|
switch seg[0] {
|
||||||
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 areaSite:
|
||||||
|
// The member's own public root: they read/write it, the world reads it
|
||||||
|
// anonymously at ~<name>.
|
||||||
|
areaRoot, area, writable = s.svc.siteRoot(s.user.Name), areaSite, true
|
||||||
case areaPublic:
|
case areaPublic:
|
||||||
areaRoot, area, writable = s.svc.pubRoot(), areaPublic, s.pubWrite
|
areaRoot, area, writable = s.svc.pubRoot(), areaPublic, s.pubWrite
|
||||||
default:
|
default:
|
||||||
|
|
@ -152,7 +165,7 @@ func (s *session) entries(vpath string) ([]Entry, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if res.root {
|
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)
|
des, err := os.ReadDir(res.real)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -296,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) // shared public area is operator-managed, unmetered
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
@ -311,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
|
||||||
|
|
@ -373,9 +386,9 @@ 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
|
// The shared public area is operator-managed (no per-user quota); the
|
||||||
// workspace is metered.
|
// member's own areas (/me and /site) are metered.
|
||||||
if res.area != areaMe {
|
if !metered(res.area) {
|
||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
return "aWriter{f: f, sess: s, tracked: startSize}, 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 {
|
switch r.Method {
|
||||||
case "List":
|
case "List":
|
||||||
if res.root {
|
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)
|
entries, err := os.ReadDir(res.real)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -57,13 +58,15 @@ func (s *Service) WebHandler(cfg WebConfig) http.Handler {
|
||||||
}
|
}
|
||||||
h := &webSrv{svc: s, cfg: cfg, sess: map[string]webSession{}}
|
h := &webSrv{svc: s, cfg: cfg, sess: map[string]webSession{}}
|
||||||
mux := http.NewServeMux()
|
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("/login", h.handleLogin)
|
||||||
mux.HandleFunc("/logout", h.handleLogout)
|
mux.HandleFunc("/logout", h.handleLogout)
|
||||||
mux.HandleFunc("/download", h.handleDownload)
|
mux.HandleFunc("/download", h.handleDownload)
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
@ -121,9 +124,16 @@ func randHex(n int) string {
|
||||||
// --- handlers ---------------------------------------------------------------
|
// --- handlers ---------------------------------------------------------------
|
||||||
|
|
||||||
func (h *webSrv) handleRoot(w http.ResponseWriter, r *http.Request) {
|
func (h *webSrv) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Anonymous per-member public browsing: /~<name>[/...]. No session required.
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/~") {
|
||||||
|
h.handleAnon(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
name, ok := h.lookup(r)
|
name, ok := h.lookup(r)
|
||||||
if !ok {
|
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
|
return
|
||||||
}
|
}
|
||||||
vpath := cleanVPath(r.URL.Query().Get("path"))
|
vpath := cleanVPath(r.URL.Query().Get("path"))
|
||||||
|
|
@ -171,9 +181,14 @@ func (h *webSrv) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
func (h *webSrv) handleLogin(w http.ResponseWriter, r *http.Request) {
|
func (h *webSrv) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
|
// 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)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.renderLogin(w, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
_ = r.ParseForm()
|
_ = r.ParseForm()
|
||||||
user := strings.TrimSpace(r.FormValue("user"))
|
user := strings.TrimSpace(r.FormValue("user"))
|
||||||
pass := r.FormValue("pass")
|
pass := r.FormValue("pass")
|
||||||
|
|
@ -315,6 +330,128 @@ func (h *webSrv) renderLogin(w http.ResponseWriter, errMsg string) {
|
||||||
_ = loginTmpl.Execute(w, loginData{Title: h.cfg.Title, Err: errMsg})
|
_ = 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 /~<name>. Directories render a
|
||||||
|
// browse listing; files stream with a content type and a short cache. It is
|
||||||
|
// confined to the area root by the same safeJoin guard as SFTP — there is no
|
||||||
|
// path to a member's private /me or above the area root.
|
||||||
|
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 ------------------------------------------------
|
// --- view models + templates ------------------------------------------------
|
||||||
|
|
||||||
type loginData struct {
|
type loginData struct {
|
||||||
|
|
@ -416,7 +553,63 @@ 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">Also reachable over SFTP: <code>sftp files@files.profullstack.com</code> (with your SSH key).</p>
|
<p class=muted style="margin-top:20px">Your <code>/site</code> files are public at <a href="/~{{.User}}/">~{{.User}}</a>. Also reachable over SFTP with your SSH key: <code>sftp files@{{.Title}}</code>.</p>
|
||||||
|
</div></body></html>`))
|
||||||
|
|
||||||
|
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(`<!doctype html><html><head><meta charset=utf-8>
|
||||||
|
<meta name=viewport content="width=device-width,initial-scale=1"><title>{{.Title}}</title><style>` + baseCSS + `</style></head>
|
||||||
|
<body><div class=wrap>
|
||||||
|
<div class=bar><h1>{{.Title}}</h1><div class=muted><a href="/public/">/public</a> · <a href="/login">Sign in</a></div></div>
|
||||||
|
{{if .Err}}<div class="flash err">{{.Err}}</div>{{end}}
|
||||||
|
<p class=muted>Public member sites. Each links to that member's shared <code>/site</code> files. <a href="/login">Sign in</a> to manage your own files (private <code>/me</code> + your <code>/site</code>).</p>
|
||||||
|
<table><tr><th>Member</th><th class=right>Size</th></tr>
|
||||||
|
{{range .Peers}}<tr><td>📂 <a href="{{.URL}}">~{{.Name}}</a></td><td class=right>{{.UsedH}}</td></tr>{{end}}
|
||||||
|
{{if not .Peers}}<tr><td colspan=2 class=muted>No public sites yet — sign in and add files to <code>/site</code>.</td></tr>{{end}}
|
||||||
|
</table>
|
||||||
|
<p class=muted style="margin-top:20px">Publish over SFTP: <code>scp file files@{{.Title}}:/site/</code> → appears at <code>~yourname</code>.</p>
|
||||||
|
</div></body></html>`))
|
||||||
|
|
||||||
|
var anonTmpl = template.Must(template.New("anon").Parse(`<!doctype html><html><head><meta charset=utf-8>
|
||||||
|
<meta name=viewport content="width=device-width,initial-scale=1"><title>{{.CurPath}} · {{.Title}}</title><style>` + baseCSS + `</style></head>
|
||||||
|
<body><div class=wrap>
|
||||||
|
<div class=bar><h1>{{.Title}}</h1><div class=muted><a href="/">all sites</a> · <a href="/login">Sign in</a></div></div>
|
||||||
|
<div class=crumbs><b>{{.CurPath}}</b></div>
|
||||||
|
<p class=muted>Read-only public area.</p>
|
||||||
|
<table><tr><th>Name</th><th class=right>Size</th><th>Modified</th></tr>
|
||||||
|
{{if .UpOK}}<tr><td><a href="{{.UpURL}}">⬑ ..</a></td><td></td><td></td></tr>{{end}}
|
||||||
|
{{range .Entries}}<tr>
|
||||||
|
<td>{{if .IsDir}}📁 <a href="{{.URL}}">{{.Name}}/</a>{{else}}📄 <a href="{{.URL}}">{{.Name}}</a>{{end}}</td>
|
||||||
|
<td class=right>{{if not .IsDir}}{{.SizeH}}{{end}}</td><td class=muted>{{.Mod}}</td></tr>{{end}}
|
||||||
|
{{if not .Entries}}<tr><td colspan=3 class=muted>(empty)</td></tr>{{end}}
|
||||||
|
</table>
|
||||||
</div></body></html>`))
|
</div></body></html>`))
|
||||||
|
|
||||||
// --- path helpers -----------------------------------------------------------
|
// --- path helpers -----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestWebPublicReadOnlyByDefault(t *testing.T) {
|
||||||
h, _ := webTestHandler(t)
|
h, _ := webTestHandler(t)
|
||||||
// Log in.
|
// Log in.
|
||||||
|
|
|
||||||
24
setup.sh
24
setup.sh
|
|
@ -700,19 +700,19 @@ FILES_SITE="
|
||||||
${FILES_DOMAIN} {
|
${FILES_DOMAIN} {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
# Public file area — unauthenticated, read-only HTTP for the shared /public
|
# Everything is served by the agentbbs file-manager on loopback. It serves,
|
||||||
# directory, so download links (e.g. extension .crx/.zip) work for everyone.
|
# all from the same virtual storage as SFTP:
|
||||||
# Maps 1:1 to the SFTP path: a member who runs
|
# / public directory of members' ~user sites (no auth)
|
||||||
|
# /~<name>[/...] 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/
|
# scp dist.crx files@${FILES_DOMAIN}:/public/extensions/acme/
|
||||||
# gets the URL https://${FILES_DOMAIN}/public/extensions/acme/dist.crx .
|
# -> https://${FILES_DOMAIN}/public/extensions/acme/dist.crx
|
||||||
# Everything else falls through to the auth'd web file manager below.
|
# scp index.html files@${FILES_DOMAIN}:/site/
|
||||||
handle_path /public/* {
|
# -> https://${FILES_DOMAIN}/~<name>/index.html
|
||||||
root * ${DATA_DIR}/files/public
|
# The anon surface has no route to a member's private /me (see internal/files
|
||||||
header Cache-Control \"public, max-age=300\"
|
# web tests); it is structurally read-only and area-confined.
|
||||||
file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
# Member web file manager (webmail-password login; /me + /public browsing).
|
|
||||||
reverse_proxy http://${FILES_WEB_ADDR}
|
reverse_proxy http://${FILES_WEB_ADDR}
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue