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

@ -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 ~<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.
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 ~<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
// 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
}