Add admin interface

This commit is contained in:
2026-09-13 00:27:29 +02:00
parent 505e4f472f
commit ca34f1506d
12 changed files with 576 additions and 13 deletions
+149
View File
@@ -0,0 +1,149 @@
package server
import (
"cmp"
"net/http"
"slices"
"time"
"send/internal/config"
)
// adminPage is the one view that shows every object, regardless of who
// uploaded it. It exists because "admin" otherwise only means "may delete
// anyone's file", with no way to see whose files those are.
type adminPage struct {
page
Objects []adminObject
Tokens []adminToken
Sort string
Count int
Total string
Quota string // empty when there is no quota
QuotaPct int
FreeDisk string
Anonymous int
}
type adminObject struct {
ID string
Filename string
Size string
Bytes int64
Owner string
Created string
Expires string
Vanity bool
}
type adminToken struct {
Name string
MaxSize string
MaxExpiry string
Vanity bool
Admin bool
}
// adminSorts maps the sort parameter to a comparison. Restricting to this set
// keeps the parameter from reaching anything that interprets it.
var adminSorts = map[string]func(a, b adminObject) int{
"created": func(a, b adminObject) int { return cmp.Compare(b.Created, a.Created) },
"expires": func(a, b adminObject) int { return cmp.Compare(a.Expires, b.Expires) },
"size": func(a, b adminObject) int { return cmp.Compare(b.Bytes, a.Bytes) },
"name": func(a, b adminObject) int { return cmp.Compare(a.ID, b.ID) },
"owner": func(a, b adminObject) int { return cmp.Compare(a.Owner, b.Owner) },
}
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
lim, err := s.limitsFor(credential(r))
switch {
case err != nil:
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
return
case lim.Anonymous():
s.fail(w, r, http.StatusUnauthorized, "This page needs an admin token.")
return
case !lim.Admin:
s.fail(w, r, http.StatusForbidden, "That token is not an admin token.")
return
}
sortBy := r.URL.Query().Get("sort")
if _, ok := adminSorts[sortBy]; !ok {
sortBy = "created"
}
now := s.now()
objects := make([]adminObject, 0, s.store.Count())
anonymous := 0
for _, m := range s.store.List() {
// Expired objects are logically gone even if the sweeper has not yet
// reached them, so they are not listed as though they were still here.
if m.Expired(now) {
continue
}
if m.Owner == "" {
anonymous++
}
objects = append(objects, adminObject{
ID: m.ID,
Filename: m.Filename,
Size: config.FormatBytes(m.Size),
Bytes: m.Size,
Owner: m.Owner,
Created: m.Created.UTC().Format(time.RFC3339),
Expires: expiresSortable(m.Expires),
Vanity: m.Vanity,
})
}
slices.SortStableFunc(objects, adminSorts[sortBy])
data := adminPage{
page: s.page(r, "Administration", true),
Objects: objects,
Tokens: s.adminTokens(),
Sort: sortBy,
Count: len(objects),
Total: config.FormatBytes(s.store.Total()),
Anonymous: anonymous,
}
if s.cfg.MaxTotalBytes != config.Unlimited {
data.Quota = config.FormatSize(s.cfg.MaxTotalBytes)
data.QuotaPct = int(min(100, s.store.Total()*100/max(1, s.cfg.MaxTotalBytes)))
}
if free, ok := freeBytes(s.store.DataDir()); ok {
data.FreeDisk = config.FormatBytes(free)
}
s.render(w, http.StatusOK, "admin.html", data)
}
// adminTokens describes the configured credentials. Only names and limits are
// exposed; the hashes stay where they are, and minting stays in the CLI, where
// it is not reachable over the network at all.
func (s *Server) adminTokens() []adminToken {
if err := s.tokens.MaybeReload(); err != nil {
s.log.Error("reloading token file", "err", err)
}
var out []adminToken
for _, t := range s.tokens.List() {
l := t.Limits(s.cfg)
out = append(out, adminToken{
Name: t.Name,
MaxSize: config.FormatSize(l.MaxSize),
MaxExpiry: config.FormatDuration(l.MaxExpiry),
Vanity: l.AllowVanity,
Admin: l.Admin,
})
}
return out
}
// expiresSortable renders a deadline so that string ordering is chronological
// and "never" sorts last.
func expiresSortable(t *time.Time) string {
if t == nil {
return "never"
}
return t.UTC().Format(time.RFC3339)
}
+8 -1
View File
@@ -47,8 +47,15 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id})
return
}
// Deleting from the administration table goes back to it. The destination
// is built from configuration, never from the request, so this cannot be
// turned into an open redirect.
if r.PostFormValue("from") == "admin" {
http.Redirect(w, r, s.cfg.BasePath+"admin", http.StatusSeeOther)
return
}
s.render(w, http.StatusOK, "error.html", errorPage{
page: s.page("Deleted", false),
page: s.page(r, "Deleted", false),
Status: "Deleted",
Message: "The file is gone.",
})
+2 -2
View File
@@ -68,7 +68,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
}
s.render(w, http.StatusOK, "index.html", indexPage{
page: s.page("Upload", true),
page: s.page(r, "Upload", true),
MaxSize: config.FormatSize(lim.MaxSize),
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
@@ -138,7 +138,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
return
}
s.render(w, http.StatusOK, "info.html", objectPage{
page: s.page(m.Filename, false),
page: s.page(r, m.Filename, false),
Meta: m,
Size: config.FormatSize(m.Size),
Expires: describeExpiry(m.Expires, s.now()),
+12 -4
View File
@@ -59,6 +59,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("POST /api/upload", s.handleUpload)
mux.HandleFunc("GET /api/limits", s.handleLimits)
mux.HandleFunc("GET /admin", s.handleAdmin)
mux.HandleFunc("GET /d/{id}", s.handleDownload)
mux.HandleFunc("GET /i/{id}", s.handleInfo)
mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete)
@@ -150,7 +151,7 @@ func bearer(r *http.Request) string {
// --- rendering -----------------------------------------------------------
var pageNames = []string{"index.html", "result.html", "info.html", "error.html"}
var pageNames = []string{"index.html", "result.html", "info.html", "error.html", "admin.html"}
// parsePages pairs each page with the shared layout. They cannot all be parsed
// into one template set because every page defines "content".
@@ -172,10 +173,17 @@ type page struct {
Base string
Title string
Script bool
Admin bool // show the administration link in the header
}
func (s *Server) page(title string, script bool) page {
return page{Base: s.cfg.BasePath, Title: title, Script: script}
// page builds the common fields, resolving whether the caller is an admin so
// the header can offer the link only to someone who can use it.
func (s *Server) page(r *http.Request, title string, script bool) page {
admin := false
if lim, err := s.limitsFor(credential(r)); err == nil {
admin = lim.Admin
}
return page{Base: s.cfg.BasePath, Title: title, Script: script, Admin: admin}
}
func (s *Server) render(w http.ResponseWriter, status int, name string, data any) {
@@ -222,7 +230,7 @@ func (s *Server) fail(w http.ResponseWriter, r *http.Request, status int, msg st
return
}
s.render(w, status, "error.html", errorPage{
page: s.page(http.StatusText(status), false),
page: s.page(r, http.StatusText(status), false),
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
Message: msg,
})
+250
View File
@@ -10,6 +10,7 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
@@ -1023,3 +1024,252 @@ func TestUploadJSONCarriesBothLinks(t *testing.T) {
}
}
}
// --- administration ------------------------------------------------------
func (h *harness) get(t *testing.T, path, token string) *http.Response {
t.Helper()
req, _ := http.NewRequest("GET", h.ts.URL+path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
// The admin page shows every stored file, so who may open it is the whole
// security story for this feature.
func TestAdminPageAccessControl(t *testing.T) {
h := newHarness(t, nil)
cases := []struct {
who string
token string
want int
}{
{"anonymous", "", http.StatusUnauthorized},
{"an unknown token", "not-a-token", http.StatusUnauthorized},
{"a non-admin token", h.token, http.StatusForbidden},
{"an admin token", h.admin, http.StatusOK},
}
for _, c := range cases {
resp := h.get(t, "/admin", c.token)
resp.Body.Close()
if resp.StatusCode != c.want {
t.Errorf("GET /admin as %s => %s, want %d", c.who, resp.Status, c.want)
}
}
}
// The cookie is the credential a browser actually uses for this page.
func TestAdminPageAcceptsTheRememberedCookie(t *testing.T) {
h := newHarness(t, nil)
req, _ := http.NewRequest("GET", h.ts.URL+"/admin", nil)
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /admin with an admin cookie => %s", resp.Status)
}
}
func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) {
h := newHarness(t, nil)
// One anonymous, one owned, one that will have expired by the time the
// page is rendered.
h.upload(t, []byte("anon"), map[string]string{
"Content-Disposition": `attachment; filename="anonymous.bin"`}).Body.Close()
h.upload(t, []byte("owned"), map[string]string{
"Authorization": "Bearer " + h.token,
"Vanity": "friends-file",
"Content-Disposition": `attachment; filename="owned.bin"`}).Body.Close()
h.upload(t, []byte("gone"), map[string]string{
"Expiry": "1h",
"Content-Disposition": `attachment; filename="expired.bin"`}).Body.Close()
h.now = clock.Add(2 * time.Hour)
resp := h.get(t, "/admin", h.admin)
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
page := string(raw)
for _, want := range []string{"anonymous.bin", "owned.bin", "friends-file", "friend"} {
if !strings.Contains(page, want) {
t.Errorf("the admin page does not list %q", want)
}
}
if strings.Contains(page, "expired.bin") {
t.Error("the admin page lists an expired file as though it were still stored")
}
// Token names and limits are shown; nothing secret is.
if !strings.Contains(page, "boss") {
t.Error("the token table does not list the tokens")
}
for _, secret := range []string{h.admin, h.token} {
if strings.Contains(page, secret) {
t.Error("the admin page echoes a token secret")
}
if strings.Contains(page, auth.HashSecret(secret)) {
t.Error("the admin page exposes a token hash")
}
}
}
func TestAdminSortIsRestrictedToKnownColumns(t *testing.T) {
h := newHarness(t, nil)
h.upload(t, []byte("x"), nil).Body.Close()
for _, sort := range []string{"size", "created", "expires", "name", "owner", "", "../../etc", "nonsense"} {
resp := h.get(t, "/admin?sort="+url.QueryEscape(sort), h.admin)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("sort=%q => %s", sort, resp.Status)
}
}
}
// Deleting from the table returns to the table rather than to a dead end.
func TestAdminDeleteReturnsToTheTable(t *testing.T) {
h := newHarness(t, nil)
res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil))
client := *h.ts.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
form := strings.NewReader("from=admin")
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "text/html")
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %s, want 303", resp.Status)
}
if loc := resp.Header.Get("Location"); loc != "/admin" {
t.Errorf("Location = %q, want /admin", loc)
}
if _, err := h.store.Get(res.ID, h.now); err == nil {
t.Error("the file was not deleted")
}
}
// A non-admin must not be able to delete someone else's file from that form.
func TestAdminDeleteStillRequiresAdmin(t *testing.T) {
h := newHarness(t, nil)
res := decode[uploadResult](t, h.upload(t, []byte("not yours"), nil))
form := strings.NewReader("from=admin")
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %s, want 403", resp.Status)
}
}
// The header link is the only way to discover the page, so it must appear for
// an admin and never for anyone else.
func TestAdminLinkIsShownOnlyToAdmins(t *testing.T) {
h := newHarness(t, nil)
for _, c := range []struct {
who string
token string
want bool
}{
{"anonymous", "", false},
{"a non-admin token", h.token, false},
{"an admin token", h.admin, true},
} {
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
if c.token != "" {
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: c.token})
}
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if got := strings.Contains(string(raw), `href="/admin"`); got != c.want {
t.Errorf("admin link shown to %s = %v, want %v", c.who, got, c.want)
}
}
}
// Mixing the two request shapes — a multipart body with the headers the raw
// shape uses — must not silently discard the options. Being handed a UUID when
// you asked for a name is worse than being told no.
func TestMultipartHonoursTheHeaderForm(t *testing.T) {
h := newHarness(t, nil)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, _ := mw.CreateFormFile("file", "build.zip")
fw.Write([]byte("payload"))
mw.Close()
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+h.token)
req.Header.Set("Vanity", "friends-build")
req.Header.Set("Expiry", "1h")
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %s", resp.Status)
}
res := decode[uploadResult](t, resp)
if res.ID != "friends-build" {
t.Errorf("id = %q, want friends-build: the Vanity header was ignored", res.ID)
}
if want := clock.Add(time.Hour).UTC().Format(time.RFC3339); res.Expires != want {
t.Errorf("expires = %q, want %q: the Expiry header was ignored", res.Expires, want)
}
}
// A form field still wins, so the browser's own controls stay authoritative.
func TestFormFieldsOverrideTheHeaders(t *testing.T) {
h := newHarness(t, nil)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
mw.WriteField("vanity", "from-the-form")
mw.WriteField("expiry", "")
fw, _ := mw.CreateFormFile("file", "build.zip")
fw.Write([]byte("payload"))
mw.Close()
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+h.token)
req.Header.Set("Vanity", "from-the-header")
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
res := decode[uploadResult](t, resp)
if res.ID != "from-the-form" {
t.Errorf("id = %q, want the form field to win", res.ID)
}
}
+17 -4
View File
@@ -101,7 +101,16 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
return
}
mr := multipart.NewReader(r.Body, boundary)
req := uploadRequest{token: bearer(r)}
// Headers seed the request even here, so that a caller mixing the two
// shapes — curl -F with a Vanity header, say — is not silently given a
// UUID instead of the name they asked for. A non-empty form field of the
// same meaning overrides them.
req := uploadRequest{
token: bearer(r),
vanity: strings.TrimSpace(r.Header.Get("Vanity")),
expiry: strings.TrimSpace(r.Header.Get("Expiry")),
}
req.explicit = req.token != ""
for n := 0; ; n++ {
@@ -147,9 +156,13 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
case "remember":
req.remember = true
case "vanity":
req.vanity = strings.TrimSpace(value)
if v := strings.TrimSpace(value); v != "" {
req.vanity = v
}
case "expiry":
req.expiry = strings.TrimSpace(value)
if v := strings.TrimSpace(value); v != "" {
req.expiry = v
}
case "filename":
req.filename = value
}
@@ -428,7 +441,7 @@ func (s *Server) respondUploaded(w http.ResponseWriter, r *http.Request, m *stor
s.render(w, http.StatusOK, "result.html", objectPage{
// The script is loaded here only to enable the copy buttons, which stay
// hidden without it rather than sitting there dead.
page: s.page("Uploaded", true),
page: s.page(r, "Uploaded", true),
Meta: m,
Size: config.FormatSize(m.Size),
Expires: describeExpiry(m.Expires, s.now()),