Add admin interface
This commit is contained in:
@@ -116,7 +116,9 @@ curl --data-binary @MyGame.zip \
|
|||||||
```
|
```
|
||||||
|
|
||||||
`Content-Disposition`, `Vanity` and `Expiry` are all optional. Without a vanity
|
`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:
|
download URL and a **delete token**, shown exactly once:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -142,10 +144,29 @@ file is accepted.
|
|||||||
| `GET /i/{id}` | a page showing name, size, expiry and digest |
|
| `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/d/{id}/delete` | delete, with `token=` in the form or `Authorization: Bearer` |
|
||||||
| `POST /api/forget` | clear a remembered token |
|
| `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
|
Deleting accepts the object's delete token, the token that uploaded it, or any
|
||||||
admin token.
|
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
|
## Data directory
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id})
|
||||||
return
|
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{
|
s.render(w, http.StatusOK, "error.html", errorPage{
|
||||||
page: s.page("Deleted", false),
|
page: s.page(r, "Deleted", false),
|
||||||
Status: "Deleted",
|
Status: "Deleted",
|
||||||
Message: "The file is gone.",
|
Message: "The file is gone.",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.render(w, http.StatusOK, "index.html", indexPage{
|
s.render(w, http.StatusOK, "index.html", indexPage{
|
||||||
page: s.page("Upload", true),
|
page: s.page(r, "Upload", true),
|
||||||
MaxSize: config.FormatSize(lim.MaxSize),
|
MaxSize: config.FormatSize(lim.MaxSize),
|
||||||
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
|
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
|
||||||
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
|
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
|
||||||
@@ -138,7 +138,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, http.StatusOK, "info.html", objectPage{
|
s.render(w, http.StatusOK, "info.html", objectPage{
|
||||||
page: s.page(m.Filename, false),
|
page: s.page(r, m.Filename, false),
|
||||||
Meta: m,
|
Meta: m,
|
||||||
Size: config.FormatSize(m.Size),
|
Size: config.FormatSize(m.Size),
|
||||||
Expires: describeExpiry(m.Expires, s.now()),
|
Expires: describeExpiry(m.Expires, s.now()),
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func (s *Server) routes() http.Handler {
|
|||||||
mux.HandleFunc("GET /{$}", s.handleIndex)
|
mux.HandleFunc("GET /{$}", s.handleIndex)
|
||||||
mux.HandleFunc("POST /api/upload", s.handleUpload)
|
mux.HandleFunc("POST /api/upload", s.handleUpload)
|
||||||
mux.HandleFunc("GET /api/limits", s.handleLimits)
|
mux.HandleFunc("GET /api/limits", s.handleLimits)
|
||||||
|
mux.HandleFunc("GET /admin", s.handleAdmin)
|
||||||
mux.HandleFunc("GET /d/{id}", s.handleDownload)
|
mux.HandleFunc("GET /d/{id}", s.handleDownload)
|
||||||
mux.HandleFunc("GET /i/{id}", s.handleInfo)
|
mux.HandleFunc("GET /i/{id}", s.handleInfo)
|
||||||
mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete)
|
mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete)
|
||||||
@@ -150,7 +151,7 @@ func bearer(r *http.Request) string {
|
|||||||
|
|
||||||
// --- rendering -----------------------------------------------------------
|
// --- 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
|
// parsePages pairs each page with the shared layout. They cannot all be parsed
|
||||||
// into one template set because every page defines "content".
|
// into one template set because every page defines "content".
|
||||||
@@ -172,10 +173,17 @@ type page struct {
|
|||||||
Base string
|
Base string
|
||||||
Title string
|
Title string
|
||||||
Script bool
|
Script bool
|
||||||
|
Admin bool // show the administration link in the header
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) page(title string, script bool) page {
|
// page builds the common fields, resolving whether the caller is an admin so
|
||||||
return page{Base: s.cfg.BasePath, Title: title, Script: script}
|
// 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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
s.render(w, status, "error.html", errorPage{
|
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)),
|
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
|
||||||
Message: msg,
|
Message: msg,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,7 +101,16 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
mr := multipart.NewReader(r.Body, boundary)
|
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 != ""
|
req.explicit = req.token != ""
|
||||||
|
|
||||||
for n := 0; ; n++ {
|
for n := 0; ; n++ {
|
||||||
@@ -147,9 +156,13 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
|
|||||||
case "remember":
|
case "remember":
|
||||||
req.remember = true
|
req.remember = true
|
||||||
case "vanity":
|
case "vanity":
|
||||||
req.vanity = strings.TrimSpace(value)
|
if v := strings.TrimSpace(value); v != "" {
|
||||||
|
req.vanity = v
|
||||||
|
}
|
||||||
case "expiry":
|
case "expiry":
|
||||||
req.expiry = strings.TrimSpace(value)
|
if v := strings.TrimSpace(value); v != "" {
|
||||||
|
req.expiry = v
|
||||||
|
}
|
||||||
case "filename":
|
case "filename":
|
||||||
req.filename = value
|
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{
|
s.render(w, http.StatusOK, "result.html", objectPage{
|
||||||
// The script is loaded here only to enable the copy buttons, which stay
|
// The script is loaded here only to enable the copy buttons, which stay
|
||||||
// hidden without it rather than sitting there dead.
|
// hidden without it rather than sitting there dead.
|
||||||
page: s.page("Uploaded", true),
|
page: s.page(r, "Uploaded", true),
|
||||||
Meta: m,
|
Meta: m,
|
||||||
Size: config.FormatSize(m.Size),
|
Size: config.FormatSize(m.Size),
|
||||||
Expires: describeExpiry(m.Expires, s.now()),
|
Expires: describeExpiry(m.Expires, s.now()),
|
||||||
|
|||||||
@@ -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
|
// reserved names would shadow a route or a well-known file if they were ever
|
||||||
// allowed into the object namespace.
|
// allowed into the object namespace.
|
||||||
var reserved = map[string]bool{
|
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,
|
"favicon.ico": true, "robots.txt": true, "index.html": true,
|
||||||
"sitemap.xml": true, "tokens.json": true, "objects": true,
|
"sitemap.xml": true, "tokens.json": true, "objects": true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,14 @@
|
|||||||
}
|
}
|
||||||
wireCopy(document);
|
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');
|
var form = document.getElementById('upload');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
|
|||||||
@@ -187,3 +187,28 @@ button.link {
|
|||||||
.copyrow { flex-wrap: wrap; }
|
.copyrow { flex-wrap: wrap; }
|
||||||
.copyrow input { flex-basis: 100%; }
|
.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; }
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<body data-base="{{.Base}}">
|
<body data-base="{{.Base}}">
|
||||||
<header>
|
<header>
|
||||||
<a class="brand" href="{{.Base}}">Uncensored Send</a>
|
<a class="brand" href="{{.Base}}">Uncensored Send</a>
|
||||||
|
{{if .Admin}}<nav><a href="{{.Base}}admin">Administration</a></nav>{{end}}
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
|
|||||||
Reference in New Issue
Block a user