diff --git a/internal/server/admin.go b/internal/server/admin.go index 3e9dbc6..5419fb2 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -2,6 +2,7 @@ package server import ( "cmp" + "fmt" "net/http" "slices" "time" @@ -32,9 +33,19 @@ type adminObject struct { Size string Bytes int64 Owner string - Created string - Expires 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 { @@ -48,13 +59,26 @@ type adminToken struct { // 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) }, + "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.ID, b.ID) }, + "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 { @@ -87,20 +111,24 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { 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, + 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.page(r, "Administration", true), + page: s.widePage(r, "Administration"), Objects: objects, Tokens: s.adminTokens(), Sort: sortBy, @@ -139,11 +167,50 @@ func (s *Server) adminTokens() []adminToken { return out } -// expiresSortable renders a deadline so that string ordering is chronological -// and "never" sorts last. -func expiresSortable(t *time.Time) string { +// 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(time.RFC3339) + 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" + } } diff --git a/internal/server/server.go b/internal/server/server.go index 6df52af..8529bc9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -196,6 +196,11 @@ type page struct { // page agrees about who you are without any script involved. User string Admin bool + + // Wide widens the page for content that is a table rather than a form. + // The reading measure that suits the upload page is far too narrow for a + // listing, which otherwise ends up behind a horizontal scrollbar. + Wide bool } // page builds the common fields, resolving the session so the header can show @@ -261,3 +266,10 @@ func (s *Server) fail(w http.ResponseWriter, r *http.Request, status int, msg st func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { s.fail(w, r, http.StatusNotFound, "No such page.") } + +// widePage is page for content that is a listing rather than a form. +func (s *Server) widePage(r *http.Request, title string) page { + p := s.page(r, title, true) + p.Wide = true + return p +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 0e31f0b..1c1b492 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -13,6 +13,8 @@ import ( "net/url" "os" "path/filepath" + "regexp" + "slices" "strings" "testing" "time" @@ -1791,3 +1793,95 @@ func TestRotationEndsLiveSessions(t *testing.T) { t.Errorf("id = %q: the rotated token lost its vanity permission", res.ID) } } + +// Sorting has to compare the underlying values, not their rendered form: two +// uploads in the same minute render identically but are not equal. +func TestAdminSortOrdersByValue(t *testing.T) { + h := newHarness(t, nil) + + // Three files, distinct in every sortable dimension. + type spec struct { + name string + size int + expiry string + } + for i, s := range []spec{ + {"big", 300, "3h"}, + {"small", 10, "1h"}, + {"medium", 100, "2h"}, + } { + h.now = clock.Add(time.Duration(i) * time.Second) // same minute, distinct instants + resp := h.upload(t, bytes.Repeat([]byte("x"), s.size), map[string]string{ + "Authorization": "Bearer " + h.token, + "Vanity": s.name, + "Expiry": s.expiry, + "Content-Disposition": `attachment; filename="` + s.name + `.bin"`, + }) + resp.Body.Close() + } + h.now = clock + + order := func(sortBy string) []string { + resp := h.get(t, "/admin?sort="+sortBy, h.admin) + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var ids []string + for _, m := range regexp.MustCompile(`class="id mono">([a-z]+)`).FindAllStringSubmatch(string(raw), -1) { + ids = append(ids, m[1]) + } + return ids + } + + for _, c := range []struct { + sortBy string + want []string + }{ + {"size", []string{"big", "medium", "small"}}, // largest first + {"expires", []string{"small", "medium", "big"}}, // soonest first + {"created", []string{"medium", "small", "big"}}, // newest first + {"name", []string{"big", "medium", "small"}}, // by filename + } { + got := order(c.sortBy) + if !slices.Equal(got, c.want) { + t.Errorf("sort=%s gave %v, want %v", c.sortBy, got, c.want) + } + } +} + +// The listing is a table, and a table needs more room than a form. It also has +// to stop being a table on a narrow screen rather than grow a scrollbar. +func TestAdminPageIsLaidOutForATable(t *testing.T) { + h := newHarness(t, nil) + h.upload(t, []byte("x"), nil).Body.Close() + + resp := h.get(t, "/admin", h.admin) + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + page := string(raw) + + if !strings.Contains(page, `class="wide"`) { + t.Error("the listing renders at the narrow reading measure meant for forms") + } + // Every cell needs its label for the stacked layout, where the header row + // is hidden. + for _, label := range []string{"Size", "Owner", "Uploaded", "Expires"} { + if !strings.Contains(page, `data-label="`+label+`"`) { + t.Errorf("cells carry no %q label, so the stacked layout loses its headings", label) + } + } + + css := h.get(t, "/static/style.css", "") + cssRaw, _ := io.ReadAll(css.Body) + css.Body.Close() + style := string(cssRaw) + + if !strings.Contains(style, "body.wide") { + t.Error("no wide layout is defined") + } + if !strings.Contains(style, "@media (max-width: 46rem)") { + t.Error("no narrow-screen rule, so the table will scroll sideways on a phone") + } + if strings.Contains(style, "overflow-x: auto") && strings.Contains(page, "tablewrap") { + t.Error("the listing still relies on a horizontal scroll container") + } +} diff --git a/web/static/style.css b/web/static/style.css index a458fb9..cdda2ec 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -202,50 +202,76 @@ header nav form { display: inline; } header nav .who { color: var(--muted); } header nav .who::before { content: "\1F511\00a0"; } +/* A listing needs far more room than the reading measure a form wants. */ +body.wide header, +body.wide main, +body.wide footer { max-width: 72rem; } + dl.stats { grid-template-columns: auto 1fr; } dl.stats em { font-style: normal; color: var(--muted); } -/* Tables scroll on their own rather than making the page scroll sideways. */ -.tablewrap { overflow-x: auto; margin: 0 -.25rem; } - table.admin { border-collapse: collapse; width: 100%; font-size: .8125rem; } -table.admin th, table.admin td { padding: .4rem .5rem; text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); } +table.admin th, +table.admin td { + padding: .45rem .5rem; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--line); +} table.admin th { color: var(--muted); font-weight: 500; white-space: nowrap; } table.admin th a { color: inherit; text-decoration: none; } table.admin th a:hover { color: var(--accent); text-decoration: underline; } table.admin tr:last-child td { border-bottom: 0; } -table.admin .num { text-align: right; white-space: nowrap; } -table.admin .small { font-size: .75rem; color: var(--muted); white-space: nowrap; } -table.admin .wrap { overflow-wrap: anywhere; min-width: 8rem; } +table.admin .num { text-align: right; } +table.admin .nowrap { white-space: nowrap; } table.admin form { margin: 0; } +table.admin .actions-cell { text-align: right; width: 1%; } + +/* The filename carries the link and the identifier sits under it, which saves + a whole column of UUID. */ +table.admin .filecell a { overflow-wrap: anywhere; font-weight: 500; } +table.admin .filecell .id { + display: block; + font-size: .6875rem; + color: var(--muted); + overflow-wrap: anywhere; +} button.small { padding: .2rem .5rem; font-size: .75rem; } +.visually-hidden { + position: absolute; + width: 1px; height: 1px; + margin: -1px; padding: 0; border: 0; + clip-path: inset(50%); + overflow: hidden; +} + +/* Narrow screens get one card per row rather than a horizontal scrollbar, + which is unusable for picking a row and pressing a button in it. */ +@media (max-width: 46rem) { + table.admin, + table.admin tbody, + table.admin tr, + table.admin td { display: block; width: 100%; } + table.admin thead { display: none; } + table.admin tr { + padding: .625rem 0; + border-bottom: 1px solid var(--line); + } + table.admin td { border: 0; padding: .1rem 0; } + table.admin td[data-label]::before { + content: attr(data-label) ": "; + color: var(--muted); + } + table.admin .num, + table.admin .actions-cell { text-align: left; width: auto; } + table.admin .actions-cell { padding-top: .5rem; } + table.admin .filecell::before { content: none; } +} + .cli code, .hint code { background: var(--bg); padding: .1rem .3rem; border-radius: 4px; } -/* Disclosure sections and inline errors ----------------------------------- */ - -details > summary { - cursor: pointer; - font-weight: 500; - padding: .125rem 0; -} -details[open] > summary { margin-bottom: .75rem; } -details .field { margin-top: .75rem; max-width: 28rem; } - -p.error { - margin: 0 0 .75rem; - padding: .5rem .75rem; - color: var(--danger); - background: var(--warn-bg); - border: 1px solid var(--danger); - border-radius: 6px; - font-size: .875rem; -} - -.card .actions { margin: 1.25rem 0; } -.card > .field:last-child { margin-bottom: 0; } - /* One-off token, tucked away so the common path stays a single button. */ .onceoff { margin-bottom: 1rem; } .onceoff summary { font-weight: 400; font-size: .875rem; color: var(--muted); } diff --git a/web/templates/admin.html b/web/templates/admin.html index 994a9f0..ff1a8cc 100644 --- a/web/templates/admin.html +++ b/web/templates/admin.html @@ -13,69 +13,66 @@ {{if not .Objects}}

Nothing stored right now.

{{else}} -
- - - - - - - - - - - - - - {{range .Objects}} - - - - - - - - - - {{end}} - -
NameFilenameSizeOwnerUploadedExpires
{{.ID}}{{if .Vanity}} ★{{end}}{{.Filename}}{{.Size}}{{if .Owner}}{{.Owner}}{{else}}anonymous{{end}}{{.Created}}{{.Expires}} -
- - -
-
-
+ + + + + + + + + + + + + {{range .Objects}} + + + + + + + + + {{end}} + +
FileSizeOwnerUploadedExpiresActions
+ {{.Filename}} + {{.ID}}{{if .Vanity}} ★{{end}} + {{.Size}}{{if .Owner}}{{.Owner}}{{else}}anonymous{{end}}{{.CreatedAgo}}{{.ExpiresIn}} +
+ + +
+
{{end}}

Tokens

- Tokens are minted and revoked with send token on the server. - They are deliberately not manageable from here: nothing that hands out - credentials should be reachable over the network. + Tokens are minted, rotated and revoked with ./send token on the + server. They are deliberately not manageable from here: nothing that hands + out credentials should be reachable over the network.

{{if .Tokens}} -
- - - - - - {{range .Tokens}} - - - - - - - - {{end}} - -
NameMax sizeMax lifetimeVanityAdmin
{{.Name}}{{.MaxSize}}{{.MaxExpiry}}{{if .Vanity}}yes{{else}}no{{end}}{{if .Admin}}yes{{else}}no{{end}}
-
+ + + + + + {{range .Tokens}} + + + + + + + + {{end}} + +
NameMax sizeMax lifetimeVanityAdmin
{{.Name}}{{.MaxSize}}{{.MaxExpiry}}{{if .Vanity}}yes{{else}}no{{end}}{{if .Admin}}yes{{else}}no{{end}}
{{end}}
{{end}} diff --git a/web/templates/layout.html b/web/templates/layout.html index 22c0379..7204735 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -6,7 +6,7 @@ {{.Title}} · Uncensored Send - +
Uncensored Send