Files
uncensored-send/internal/server/admin.go
T
2026-09-13 11:32:33 +02:00

252 lines
7.0 KiB
Go

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 := "My files"
if lim.Admin {
title = "Administration"
}
// 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"
}
}