package server import ( "cmp" "fmt" "net/http" "slices" "time" "uncensored-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.FormatLifetime(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" } }