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) }