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}}
-
-
-
-
- Name
- Filename
- Size
- Owner
- Uploaded
- Expires
-
-
-
-
- {{range .Objects}}
-
- {{.ID}} {{if .Vanity}} ★ {{end}}
- {{.Filename}}
- {{.Size}}
- {{if .Owner}}{{.Owner}}{{else}}anonymous {{end}}
- {{.Created}}
- {{.Expires}}
-
-
-
-
- {{end}}
-
-
-
+
+
+
+ File
+ Size
+ Owner
+ Uploaded
+ Expires
+ Actions
+
+
+
+ {{range .Objects}}
+
+
+ {{.Filename}}
+ {{.ID}}{{if .Vanity}} ★ {{end}}
+
+ {{.Size}}
+ {{if .Owner}}{{.Owner}}{{else}}anonymous {{end}}
+ {{.CreatedAgo}}
+ {{.ExpiresIn}}
+
+
+
+
+ {{end}}
+
+
{{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}}
-
-
-
- Name Max size Max lifetime Vanity Admin
-
-
- {{range .Tokens}}
-
- {{.Name}}
- {{.MaxSize}}
- {{.MaxExpiry}}
- {{if .Vanity}}yes{{else}}no{{end}}
- {{if .Admin}}yes{{else}}no{{end}}
-
- {{end}}
-
-
-
+
+
+ Name Max size Max lifetime Vanity Admin
+
+
+ {{range .Tokens}}
+
+ {{.Name}}
+ {{.MaxSize}}
+ {{.MaxExpiry}}
+ {{if .Vanity}}yes{{else}}no{{end}}
+ {{if .Admin}}yes{{else}}no{{end}}
+
+ {{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
-
+