Add admin interface

This commit is contained in:
2026-09-13 00:27:29 +02:00
parent 505e4f472f
commit ca34f1506d
12 changed files with 576 additions and 13 deletions
+22 -1
View File
@@ -116,7 +116,9 @@ curl --data-binary @MyGame.zip \
```
`Content-Disposition`, `Vanity` and `Expiry` are all optional. Without a vanity
name you get a UUIDv4; vanity names require a token. The reply carries the
name you get a UUIDv4; vanity names require a token. These headers work with a
`multipart/form-data` body too, where a non-empty form field of the same name
overrides them. The reply carries the
download URL and a **delete token**, shown exactly once:
```json
@@ -142,10 +144,29 @@ file is accepted.
| `GET /i/{id}` | a page showing name, size, expiry and digest |
| `POST /api/d/{id}/delete` | delete, with `token=` in the form or `Authorization: Bearer` |
| `POST /api/forget` | clear a remembered token |
| `GET /admin` | administration page; admin tokens only |
Deleting accepts the object's delete token, the token that uploaded it, or any
admin token.
## Administration
An admin token adds a page at `/admin`, linked from the header whenever the
token you are using is one. It is the view that privilege is for: every stored
file, whoever uploaded it, with a delete button on each row.
- Files, with size, owner, upload time and expiry, sortable by column. Expired
files are not listed even if the sweeper has not reached them yet, since they
are already gone as far as anything else is concerned.
- Totals: how many files, how much is stored, how much of the quota is used and
how much disk is left.
- The configured tokens with their limits — names only. Hashes are never
rendered, and there is no way to mint or revoke a token from the page.
Anything that hands out credentials stays in the CLI, off the network.
Deleting from the table returns to the table. A non-admin gets `403` and never
sees the link.
## Data directory
```
+149
View File
@@ -0,0 +1,149 @@
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)
}
+8 -1
View File
@@ -47,8 +47,15 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id})
return
}
// Deleting from the administration table goes back to it. The destination
// is built from configuration, never from the request, so this cannot be
// turned into an open redirect.
if r.PostFormValue("from") == "admin" {
http.Redirect(w, r, s.cfg.BasePath+"admin", http.StatusSeeOther)
return
}
s.render(w, http.StatusOK, "error.html", errorPage{
page: s.page("Deleted", false),
page: s.page(r, "Deleted", false),
Status: "Deleted",
Message: "The file is gone.",
})
+2 -2
View File
@@ -68,7 +68,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
}
s.render(w, http.StatusOK, "index.html", indexPage{
page: s.page("Upload", true),
page: s.page(r, "Upload", true),
MaxSize: config.FormatSize(lim.MaxSize),
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
@@ -138,7 +138,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
return
}
s.render(w, http.StatusOK, "info.html", objectPage{
page: s.page(m.Filename, false),
page: s.page(r, m.Filename, false),
Meta: m,
Size: config.FormatSize(m.Size),
Expires: describeExpiry(m.Expires, s.now()),
+12 -4
View File
@@ -59,6 +59,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("POST /api/upload", s.handleUpload)
mux.HandleFunc("GET /api/limits", s.handleLimits)
mux.HandleFunc("GET /admin", s.handleAdmin)
mux.HandleFunc("GET /d/{id}", s.handleDownload)
mux.HandleFunc("GET /i/{id}", s.handleInfo)
mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete)
@@ -150,7 +151,7 @@ func bearer(r *http.Request) string {
// --- rendering -----------------------------------------------------------
var pageNames = []string{"index.html", "result.html", "info.html", "error.html"}
var pageNames = []string{"index.html", "result.html", "info.html", "error.html", "admin.html"}
// parsePages pairs each page with the shared layout. They cannot all be parsed
// into one template set because every page defines "content".
@@ -172,10 +173,17 @@ type page struct {
Base string
Title string
Script bool
Admin bool // show the administration link in the header
}
func (s *Server) page(title string, script bool) page {
return page{Base: s.cfg.BasePath, Title: title, Script: script}
// page builds the common fields, resolving whether the caller is an admin so
// the header can offer the link only to someone who can use it.
func (s *Server) page(r *http.Request, title string, script bool) page {
admin := false
if lim, err := s.limitsFor(credential(r)); err == nil {
admin = lim.Admin
}
return page{Base: s.cfg.BasePath, Title: title, Script: script, Admin: admin}
}
func (s *Server) render(w http.ResponseWriter, status int, name string, data any) {
@@ -222,7 +230,7 @@ func (s *Server) fail(w http.ResponseWriter, r *http.Request, status int, msg st
return
}
s.render(w, status, "error.html", errorPage{
page: s.page(http.StatusText(status), false),
page: s.page(r, http.StatusText(status), false),
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
Message: msg,
})
+250
View File
@@ -10,6 +10,7 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
@@ -1023,3 +1024,252 @@ func TestUploadJSONCarriesBothLinks(t *testing.T) {
}
}
}
// --- administration ------------------------------------------------------
func (h *harness) get(t *testing.T, path, token string) *http.Response {
t.Helper()
req, _ := http.NewRequest("GET", h.ts.URL+path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
// The admin page shows every stored file, so who may open it is the whole
// security story for this feature.
func TestAdminPageAccessControl(t *testing.T) {
h := newHarness(t, nil)
cases := []struct {
who string
token string
want int
}{
{"anonymous", "", http.StatusUnauthorized},
{"an unknown token", "not-a-token", http.StatusUnauthorized},
{"a non-admin token", h.token, http.StatusForbidden},
{"an admin token", h.admin, http.StatusOK},
}
for _, c := range cases {
resp := h.get(t, "/admin", c.token)
resp.Body.Close()
if resp.StatusCode != c.want {
t.Errorf("GET /admin as %s => %s, want %d", c.who, resp.Status, c.want)
}
}
}
// The cookie is the credential a browser actually uses for this page.
func TestAdminPageAcceptsTheRememberedCookie(t *testing.T) {
h := newHarness(t, nil)
req, _ := http.NewRequest("GET", h.ts.URL+"/admin", nil)
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /admin with an admin cookie => %s", resp.Status)
}
}
func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) {
h := newHarness(t, nil)
// One anonymous, one owned, one that will have expired by the time the
// page is rendered.
h.upload(t, []byte("anon"), map[string]string{
"Content-Disposition": `attachment; filename="anonymous.bin"`}).Body.Close()
h.upload(t, []byte("owned"), map[string]string{
"Authorization": "Bearer " + h.token,
"Vanity": "friends-file",
"Content-Disposition": `attachment; filename="owned.bin"`}).Body.Close()
h.upload(t, []byte("gone"), map[string]string{
"Expiry": "1h",
"Content-Disposition": `attachment; filename="expired.bin"`}).Body.Close()
h.now = clock.Add(2 * time.Hour)
resp := h.get(t, "/admin", h.admin)
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
page := string(raw)
for _, want := range []string{"anonymous.bin", "owned.bin", "friends-file", "friend"} {
if !strings.Contains(page, want) {
t.Errorf("the admin page does not list %q", want)
}
}
if strings.Contains(page, "expired.bin") {
t.Error("the admin page lists an expired file as though it were still stored")
}
// Token names and limits are shown; nothing secret is.
if !strings.Contains(page, "boss") {
t.Error("the token table does not list the tokens")
}
for _, secret := range []string{h.admin, h.token} {
if strings.Contains(page, secret) {
t.Error("the admin page echoes a token secret")
}
if strings.Contains(page, auth.HashSecret(secret)) {
t.Error("the admin page exposes a token hash")
}
}
}
func TestAdminSortIsRestrictedToKnownColumns(t *testing.T) {
h := newHarness(t, nil)
h.upload(t, []byte("x"), nil).Body.Close()
for _, sort := range []string{"size", "created", "expires", "name", "owner", "", "../../etc", "nonsense"} {
resp := h.get(t, "/admin?sort="+url.QueryEscape(sort), h.admin)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("sort=%q => %s", sort, resp.Status)
}
}
}
// Deleting from the table returns to the table rather than to a dead end.
func TestAdminDeleteReturnsToTheTable(t *testing.T) {
h := newHarness(t, nil)
res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil))
client := *h.ts.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
form := strings.NewReader("from=admin")
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "text/html")
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %s, want 303", resp.Status)
}
if loc := resp.Header.Get("Location"); loc != "/admin" {
t.Errorf("Location = %q, want /admin", loc)
}
if _, err := h.store.Get(res.ID, h.now); err == nil {
t.Error("the file was not deleted")
}
}
// A non-admin must not be able to delete someone else's file from that form.
func TestAdminDeleteStillRequiresAdmin(t *testing.T) {
h := newHarness(t, nil)
res := decode[uploadResult](t, h.upload(t, []byte("not yours"), nil))
form := strings.NewReader("from=admin")
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %s, want 403", resp.Status)
}
}
// The header link is the only way to discover the page, so it must appear for
// an admin and never for anyone else.
func TestAdminLinkIsShownOnlyToAdmins(t *testing.T) {
h := newHarness(t, nil)
for _, c := range []struct {
who string
token string
want bool
}{
{"anonymous", "", false},
{"a non-admin token", h.token, false},
{"an admin token", h.admin, true},
} {
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
if c.token != "" {
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: c.token})
}
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if got := strings.Contains(string(raw), `href="/admin"`); got != c.want {
t.Errorf("admin link shown to %s = %v, want %v", c.who, got, c.want)
}
}
}
// Mixing the two request shapes — a multipart body with the headers the raw
// shape uses — must not silently discard the options. Being handed a UUID when
// you asked for a name is worse than being told no.
func TestMultipartHonoursTheHeaderForm(t *testing.T) {
h := newHarness(t, nil)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, _ := mw.CreateFormFile("file", "build.zip")
fw.Write([]byte("payload"))
mw.Close()
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+h.token)
req.Header.Set("Vanity", "friends-build")
req.Header.Set("Expiry", "1h")
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %s", resp.Status)
}
res := decode[uploadResult](t, resp)
if res.ID != "friends-build" {
t.Errorf("id = %q, want friends-build: the Vanity header was ignored", res.ID)
}
if want := clock.Add(time.Hour).UTC().Format(time.RFC3339); res.Expires != want {
t.Errorf("expires = %q, want %q: the Expiry header was ignored", res.Expires, want)
}
}
// A form field still wins, so the browser's own controls stay authoritative.
func TestFormFieldsOverrideTheHeaders(t *testing.T) {
h := newHarness(t, nil)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
mw.WriteField("vanity", "from-the-form")
mw.WriteField("expiry", "")
fw, _ := mw.CreateFormFile("file", "build.zip")
fw.Write([]byte("payload"))
mw.Close()
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+h.token)
req.Header.Set("Vanity", "from-the-header")
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
res := decode[uploadResult](t, resp)
if res.ID != "from-the-form" {
t.Errorf("id = %q, want the form field to win", res.ID)
}
}
+17 -4
View File
@@ -101,7 +101,16 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
return
}
mr := multipart.NewReader(r.Body, boundary)
req := uploadRequest{token: bearer(r)}
// Headers seed the request even here, so that a caller mixing the two
// shapes — curl -F with a Vanity header, say — is not silently given a
// UUID instead of the name they asked for. A non-empty form field of the
// same meaning overrides them.
req := uploadRequest{
token: bearer(r),
vanity: strings.TrimSpace(r.Header.Get("Vanity")),
expiry: strings.TrimSpace(r.Header.Get("Expiry")),
}
req.explicit = req.token != ""
for n := 0; ; n++ {
@@ -147,9 +156,13 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
case "remember":
req.remember = true
case "vanity":
req.vanity = strings.TrimSpace(value)
if v := strings.TrimSpace(value); v != "" {
req.vanity = v
}
case "expiry":
req.expiry = strings.TrimSpace(value)
if v := strings.TrimSpace(value); v != "" {
req.expiry = v
}
case "filename":
req.filename = value
}
@@ -428,7 +441,7 @@ func (s *Server) respondUploaded(w http.ResponseWriter, r *http.Request, m *stor
s.render(w, http.StatusOK, "result.html", objectPage{
// The script is loaded here only to enable the copy buttons, which stay
// hidden without it rather than sitting there dead.
page: s.page("Uploaded", true),
page: s.page(r, "Uploaded", true),
Meta: m,
Size: config.FormatSize(m.Size),
Expires: describeExpiry(m.Expires, s.now()),
+1 -1
View File
@@ -16,7 +16,7 @@ var vanityRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,63}$`)
// reserved names would shadow a route or a well-known file if they were ever
// allowed into the object namespace.
var reserved = map[string]bool{
"d": true, "i": true, "api": true, "static": true,
"d": true, "i": true, "api": true, "static": true, "admin": true,
"favicon.ico": true, "robots.txt": true, "index.html": true,
"sitemap.xml": true, "tokens.json": true, "objects": true,
}
+8
View File
@@ -28,6 +28,14 @@
}
wireCopy(document);
// Destructive buttons ask first. The attribute carries the question, so the
// markup decides what is worth confirming and this stays generic.
Array.prototype.forEach.call(document.querySelectorAll('[data-confirm]'), function (button) {
button.addEventListener('click', function (e) {
if (!window.confirm(button.dataset.confirm)) e.preventDefault();
});
});
var form = document.getElementById('upload');
if (!form) return;
+25
View File
@@ -187,3 +187,28 @@ button.link {
.copyrow { flex-wrap: wrap; }
.copyrow input { flex-basis: 100%; }
}
/* Administration --------------------------------------------------------- */
header nav { float: right; font-size: .875rem; }
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 { 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 form { margin: 0; }
button.small { padding: .2rem .5rem; font-size: .75rem; }
.cli code, .hint code { background: var(--bg); padding: .1rem .3rem; border-radius: 4px; }
+81
View File
@@ -0,0 +1,81 @@
{{define "content"}}
<section class="card">
<h1>Administration</h1>
<dl class="stats">
<dt>Files</dt><dd>{{.Count}}{{if .Anonymous}} <em>({{.Anonymous}} anonymous)</em>{{end}}</dd>
<dt>Stored</dt><dd>{{.Total}}{{if .Quota}} of {{.Quota}} ({{.QuotaPct}}%){{end}}</dd>
{{if .FreeDisk}}<dt>Free disk</dt><dd>{{.FreeDisk}}</dd>{{end}}
</dl>
</section>
<section class="card">
<h2>Files</h2>
{{if not .Objects}}
<p class="hint">Nothing stored right now.</p>
{{else}}
<div class="tablewrap">
<table class="admin">
<thead>
<tr>
<th><a href="?sort=name">Name</a></th>
<th><a href="?sort=name">Filename</a></th>
<th class="num"><a href="?sort=size">Size</a></th>
<th><a href="?sort=owner">Owner</a></th>
<th><a href="?sort=created">Uploaded</a></th>
<th><a href="?sort=expires">Expires</a></th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Objects}}
<tr>
<td class="mono"><a href="{{$.Base}}i/{{.ID}}">{{.ID}}</a>{{if .Vanity}} <em title="custom name">★</em>{{end}}</td>
<td class="wrap">{{.Filename}}</td>
<td class="num">{{.Size}}</td>
<td>{{if .Owner}}{{.Owner}}{{else}}<em>anonymous</em>{{end}}</td>
<td class="mono small">{{.Created}}</td>
<td class="mono small">{{.Expires}}</td>
<td>
<form method="post" action="{{$.Base}}api/d/{{.ID}}/delete">
<input type="hidden" name="from" value="admin">
<button type="submit" class="danger small"
data-confirm="Delete {{.Filename}}?">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</section>
<section class="card">
<h2>Tokens</h2>
<p class="hint">
Tokens are minted and revoked with <code>send token</code> on the server.
They are deliberately not manageable from here: nothing that hands out
credentials should be reachable over the network.
</p>
{{if .Tokens}}
<div class="tablewrap">
<table class="admin">
<thead>
<tr><th>Name</th><th>Max size</th><th>Max lifetime</th><th>Vanity</th><th>Admin</th></tr>
</thead>
<tbody>
{{range .Tokens}}
<tr>
<td>{{.Name}}</td>
<td>{{.MaxSize}}</td>
<td>{{.MaxExpiry}}</td>
<td>{{if .Vanity}}yes{{else}}no{{end}}</td>
<td>{{if .Admin}}yes{{else}}no{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</section>
{{end}}
+1
View File
@@ -9,6 +9,7 @@
<body data-base="{{.Base}}">
<header>
<a class="brand" href="{{.Base}}">Uncensored&nbsp;Send</a>
{{if .Admin}}<nav><a href="{{.Base}}admin">Administration</a></nav>{{end}}
</header>
<main>
{{template "content" .}}