mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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:
parent
d1615ac817
commit
e478da905f
8 changed files with 486 additions and 53 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 "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 {
|
||||
|
|
|
|||
|
|
@ -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: /~<name>[/...]. 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 /~<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 ------------------------------------------------
|
||||
|
||||
type loginData struct {
|
||||
|
|
@ -416,7 +553,63 @@ var listTmpl = template.Must(template.New("list").Parse(`<!doctype html><html><h
|
|||
</tr>{{end}}
|
||||
{{if not .Entries}}<tr><td colspan=4 class=muted>(empty)</td></tr>{{end}}
|
||||
</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>`))
|
||||
|
||||
// --- 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) {
|
||||
h, _ := webTestHandler(t)
|
||||
// Log in.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue