From ca34f1506d1ae79152e94924e82767c073dacb78 Mon Sep 17 00:00:00 2001 From: Thayol Date: Sun, 13 Sep 2026 00:27:29 +0200 Subject: [PATCH] Add admin interface --- README.md | 23 ++- internal/server/admin.go | 149 ++++++++++++++++++++ internal/server/delete.go | 9 +- internal/server/pages.go | 4 +- internal/server/server.go | 16 ++- internal/server/server_test.go | 250 +++++++++++++++++++++++++++++++++ internal/server/upload.go | 21 ++- internal/store/id.go | 2 +- web/static/app.js | 8 ++ web/static/style.css | 25 ++++ web/templates/admin.html | 81 +++++++++++ web/templates/layout.html | 1 + 12 files changed, 576 insertions(+), 13 deletions(-) create mode 100644 internal/server/admin.go create mode 100644 web/templates/admin.html diff --git a/README.md b/README.md index 7af4e33..c4dffd9 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/internal/server/admin.go b/internal/server/admin.go new file mode 100644 index 0000000..1016607 --- /dev/null +++ b/internal/server/admin.go @@ -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) +} diff --git a/internal/server/delete.go b/internal/server/delete.go index 4f68307..ff8eb92 100644 --- a/internal/server/delete.go +++ b/internal/server/delete.go @@ -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.", }) diff --git a/internal/server/pages.go b/internal/server/pages.go index 279859c..5ca7824 100644 --- a/internal/server/pages.go +++ b/internal/server/pages.go @@ -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()), diff --git a/internal/server/server.go b/internal/server/server.go index d8ba937..d65e36c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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, }) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 2124556..79d9929 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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) + } +} diff --git a/internal/server/upload.go b/internal/server/upload.go index 21d4820..49c6b0c 100644 --- a/internal/server/upload.go +++ b/internal/server/upload.go @@ -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()), diff --git a/internal/store/id.go b/internal/store/id.go index 5df3c38..2cacbb9 100644 --- a/internal/store/id.go +++ b/internal/store/id.go @@ -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, } diff --git a/web/static/app.js b/web/static/app.js index 4365775..76bb94c 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -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; diff --git a/web/static/style.css b/web/static/style.css index 5c7d6a7..a6d749c 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -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; } diff --git a/web/templates/admin.html b/web/templates/admin.html new file mode 100644 index 0000000..994a9f0 --- /dev/null +++ b/web/templates/admin.html @@ -0,0 +1,81 @@ +{{define "content"}} +
+

Administration

+
+
Files
{{.Count}}{{if .Anonymous}} ({{.Anonymous}} anonymous){{end}}
+
Stored
{{.Total}}{{if .Quota}} of {{.Quota}} ({{.QuotaPct}}%){{end}}
+ {{if .FreeDisk}}
Free disk
{{.FreeDisk}}
{{end}} +
+
+ +
+

Files

+ {{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}} +
+ + +
+
+
+ {{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. +

+ {{if .Tokens}} +
+ + + + + + {{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 9b616b5..92527d2 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -9,6 +9,7 @@
Uncensored Send + {{if .Admin}}{{end}}
{{template "content" .}}