files: add per-user public /site + anonymous web surface

Add a third storage area, /site — each member's own public root, served
unauthenticated on the web at ~<name> alongside the shared /public.

Web file host (files.<host>) is no longer a login wall:
- GET /            -> directory of members' ~user sites (+ sign-in link)
- GET /~<name>/... -> 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.<host> to the Go manager. Docs + tests updated
(anon download/browse, traversal confinement, /site metering).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-26 00:49:38 +00:00
parent d1615ac817
commit e478da905f
8 changed files with 486 additions and 53 deletions

View file

@ -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 ~<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
// 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 ~<name>.
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 &quotaWriter{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 {