217 lines
5.7 KiB
Go
217 lines
5.7 KiB
Go
package server
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"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
|
|
Vanity bool
|
|
|
|
// Times are kept three ways: the value itself, which is what sorting
|
|
// compares; an absolute rendering for the tooltip; and a relative one,
|
|
// which is what you actually read when deciding whether a file still
|
|
// matters. Sorting on the rendered string would tie everything that
|
|
// happened within the same minute.
|
|
createdAt time.Time
|
|
expiresAt *time.Time
|
|
Created string
|
|
CreatedAgo string
|
|
Expires string
|
|
ExpiresIn string
|
|
}
|
|
|
|
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 b.createdAt.Compare(a.createdAt) },
|
|
"expires": func(a, b adminObject) int { return compareExpiry(a.expiresAt, b.expiresAt) },
|
|
"size": func(a, b adminObject) int { return cmp.Compare(b.Bytes, a.Bytes) },
|
|
"name": func(a, b adminObject) int { return cmp.Compare(a.Filename, b.Filename) },
|
|
"owner": func(a, b adminObject) int { return cmp.Compare(a.Owner, b.Owner) },
|
|
}
|
|
|
|
// compareExpiry orders soonest first, with "never" last where it belongs.
|
|
func compareExpiry(a, b *time.Time) int {
|
|
switch {
|
|
case a == nil && b == nil:
|
|
return 0
|
|
case a == nil:
|
|
return 1
|
|
case b == nil:
|
|
return -1
|
|
}
|
|
return a.Compare(*b)
|
|
}
|
|
|
|
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|
lim, err := s.limitsFor(r, 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,
|
|
Vanity: m.Vanity,
|
|
createdAt: m.Created,
|
|
expiresAt: m.Expires,
|
|
Created: absolute(&m.Created),
|
|
CreatedAgo: relative(m.Created, now),
|
|
Expires: absolute(m.Expires),
|
|
ExpiresIn: expiresRelative(m.Expires, now),
|
|
})
|
|
}
|
|
slices.SortStableFunc(objects, adminSorts[sortBy])
|
|
|
|
data := adminPage{
|
|
page: s.widePage(r, "Administration"),
|
|
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
|
|
}
|
|
|
|
// absolute renders a time for a tooltip, where the reader wants the real value
|
|
// rather than a distance from now.
|
|
func absolute(t *time.Time) string {
|
|
if t == nil {
|
|
return "never"
|
|
}
|
|
return t.UTC().Format("2006-01-02 15:04 MST")
|
|
}
|
|
|
|
func expiresRelative(t *time.Time, now time.Time) string {
|
|
if t == nil {
|
|
return "never"
|
|
}
|
|
return relative(*t, now)
|
|
}
|
|
|
|
// relative renders a time as a short distance from now - "3d ago", "in 4h" -
|
|
// which is what a listing is actually read for. The exact time stays available
|
|
// in the cell's tooltip.
|
|
func relative(t, now time.Time) string {
|
|
d := t.Sub(now)
|
|
ahead := d > 0
|
|
if !ahead {
|
|
d = -d
|
|
}
|
|
var magnitude string
|
|
switch {
|
|
case d < time.Minute:
|
|
magnitude = "now"
|
|
case d < time.Hour:
|
|
magnitude = fmt.Sprintf("%dm", int(d.Minutes()))
|
|
case d < 24*time.Hour:
|
|
magnitude = fmt.Sprintf("%dh", int(d.Hours()))
|
|
case d < 365*24*time.Hour:
|
|
magnitude = fmt.Sprintf("%dd", int(d.Hours()/24))
|
|
default:
|
|
magnitude = fmt.Sprintf("%dy", int(d.Hours()/24/365))
|
|
}
|
|
switch {
|
|
case magnitude == "now":
|
|
return "now"
|
|
case ahead:
|
|
return "in " + magnitude
|
|
default:
|
|
return magnitude + " ago"
|
|
}
|
|
}
|