package server import ( "cmp" "fmt" "net/http" "slices" "time" "uncensored-send/internal/config" ) // filesPage lists uploads. One page serves two readers: an admin sees every // object plus what the server as a whole is holding, and a token holder sees // the files uploaded under their own token, which is otherwise information // they have no way to get back. type filesPage struct { page Objects []adminObject Sort string Count int Listed string // bytes held by the files actually listed // NeedsToken replaces the listing with an invitation to log in. Uploads // are recorded against the token that made them, so there is nothing to // show someone who has not presented one. NeedsToken bool // The rest is the server's own state, and only an admin sees it. Tokens []adminToken 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) handleFiles(w http.ResponseWriter, r *http.Request) { lim, err := s.limitsFor(r, credential(r)) if err != nil { s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.") return } if lim.Anonymous() { // Not an error: the page exists, it just has nothing to say without a // token. An anonymous upload is not recorded against anyone. s.render(w, http.StatusOK, "files.html", filesPage{ page: s.page(r, "Files", false), NeedsToken: true, }) 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, listed := 0, int64(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 } // A token holder sees their own uploads and nothing else. Anonymous // files belong to no token, so they stay with the admins, which is // exactly who may delete them. if !lim.Admin && m.Owner != lim.Name { continue } if m.Owner == "" { anonymous++ } listed += m.Size 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]) title := "Your files" if lim.Admin { title = "All files" } // s.page resolves the session cookie, which is the right thing for the // header but not for this body: the page has to describe the credential it // was actually read with, or an admin presenting a bearer token is shown a // plain user's view of a listing that was built for an admin. head := s.widePage(r, title) head.User, head.Admin = lim.Name, lim.Admin data := filesPage{ page: head, Objects: objects, Sort: sortBy, Count: len(objects), Listed: config.FormatBytes(listed), } if !lim.Admin { s.render(w, http.StatusOK, "files.html", data) return } // Everything below is the server's own state rather than anyone's files. data.Tokens = s.adminTokens() data.Total = config.FormatBytes(s.store.Total()) data.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, "files.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" } }