Add files page
This commit is contained in:
+56
-21
@@ -10,16 +10,24 @@ import (
|
||||
"uncensored-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 {
|
||||
// filesPage lists uploads. One page serves two readers: an admin sees every
|
||||
// object plus what the server as a whole is holding, and a token holder sees
|
||||
// the files uploaded under their own token, which is otherwise information
|
||||
// they have no way to get back.
|
||||
type filesPage struct {
|
||||
page
|
||||
Objects []adminObject
|
||||
Tokens []adminToken
|
||||
Sort string
|
||||
|
||||
Count int
|
||||
Listed string // bytes held by the files actually listed
|
||||
|
||||
// NeedsToken replaces the listing with an invitation to log in. Uploads
|
||||
// are recorded against the token that made them, so there is nothing to
|
||||
// show someone who has not presented one.
|
||||
NeedsToken bool
|
||||
|
||||
// The rest is the server's own state, and only an admin sees it.
|
||||
Tokens []adminToken
|
||||
Total string
|
||||
Quota string // empty when there is no quota
|
||||
QuotaPct int
|
||||
@@ -79,17 +87,19 @@ func compareExpiry(a, b *time.Time) int {
|
||||
return a.Compare(*b)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) {
|
||||
lim, err := s.limitsFor(r, credential(r))
|
||||
switch {
|
||||
case err != nil:
|
||||
if 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.")
|
||||
}
|
||||
if lim.Anonymous() {
|
||||
// Not an error: the page exists, it just has nothing to say without a
|
||||
// token. An anonymous upload is not recorded against anyone.
|
||||
s.render(w, http.StatusOK, "files.html", filesPage{
|
||||
page: s.page(r, "Files", false),
|
||||
NeedsToken: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -100,16 +110,23 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
now := s.now()
|
||||
objects := make([]adminObject, 0, s.store.Count())
|
||||
anonymous := 0
|
||||
anonymous, listed := 0, int64(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
|
||||
}
|
||||
// A token holder sees their own uploads and nothing else. Anonymous
|
||||
// files belong to no token, so they stay with the admins, which is
|
||||
// exactly who may delete them.
|
||||
if !lim.Admin && m.Owner != lim.Name {
|
||||
continue
|
||||
}
|
||||
if m.Owner == "" {
|
||||
anonymous++
|
||||
}
|
||||
listed += m.Size
|
||||
objects = append(objects, adminObject{
|
||||
ID: m.ID,
|
||||
Filename: m.Filename,
|
||||
@@ -127,15 +144,33 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
slices.SortStableFunc(objects, adminSorts[sortBy])
|
||||
|
||||
data := adminPage{
|
||||
page: s.widePage(r, "Administration"),
|
||||
title := "Your files"
|
||||
if lim.Admin {
|
||||
title = "All files"
|
||||
}
|
||||
// s.page resolves the session cookie, which is the right thing for the
|
||||
// header but not for this body: the page has to describe the credential it
|
||||
// was actually read with, or an admin presenting a bearer token is shown a
|
||||
// plain user's view of a listing that was built for an admin.
|
||||
head := s.widePage(r, title)
|
||||
head.User, head.Admin = lim.Name, lim.Admin
|
||||
|
||||
data := filesPage{
|
||||
page: head,
|
||||
Objects: objects,
|
||||
Tokens: s.adminTokens(),
|
||||
Sort: sortBy,
|
||||
Count: len(objects),
|
||||
Total: config.FormatBytes(s.store.Total()),
|
||||
Anonymous: anonymous,
|
||||
Listed: config.FormatBytes(listed),
|
||||
}
|
||||
if !lim.Admin {
|
||||
s.render(w, http.StatusOK, "files.html", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Everything below is the server's own state rather than anyone's files.
|
||||
data.Tokens = s.adminTokens()
|
||||
data.Total = config.FormatBytes(s.store.Total())
|
||||
data.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)))
|
||||
@@ -143,7 +178,7 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
if free, ok := freeBytes(s.store.DataDir()); ok {
|
||||
data.FreeDisk = config.FormatBytes(free)
|
||||
}
|
||||
s.render(w, http.StatusOK, "admin.html", data)
|
||||
s.render(w, http.StatusOK, "files.html", data)
|
||||
}
|
||||
|
||||
// adminTokens describes the configured credentials. Only names and limits are
|
||||
|
||||
@@ -58,11 +58,11 @@ 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 from == "admin" {
|
||||
http.Redirect(w, r, s.cfg.BasePath+"admin", http.StatusSeeOther)
|
||||
// Deleting from the listing goes back to it. The destination is built from
|
||||
// configuration, never from the request, so this cannot be turned into an
|
||||
// open redirect.
|
||||
if from == "files" {
|
||||
http.Redirect(w, r, s.cfg.BasePath+"files", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, http.StatusOK, "error.html", errorPage{
|
||||
|
||||
@@ -25,7 +25,7 @@ type loginPage struct {
|
||||
// it to known page names means the parameter can never name somewhere else.
|
||||
var loginDestinations = map[string]string{
|
||||
"": "",
|
||||
"admin": "admin",
|
||||
"files": "files",
|
||||
}
|
||||
|
||||
func destination(next string) string {
|
||||
|
||||
@@ -68,7 +68,7 @@ func (s *Server) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", s.handleIndex)
|
||||
mux.HandleFunc("POST /upload", s.handleUpload)
|
||||
mux.HandleFunc("GET /admin", s.handleAdmin)
|
||||
mux.HandleFunc("GET /files", s.handleFiles)
|
||||
mux.HandleFunc("GET /d/{id}", s.handleDownload)
|
||||
mux.HandleFunc("GET /i/{id}", s.handleInfo)
|
||||
mux.HandleFunc("POST /d/{id}/delete", s.handleDelete)
|
||||
@@ -199,7 +199,7 @@ func bearer(r *http.Request) string {
|
||||
// --- rendering -----------------------------------------------------------
|
||||
|
||||
var pageNames = []string{"index.html", "result.html", "info.html", "error.html",
|
||||
"admin.html", "login.html"}
|
||||
"files.html", "login.html"}
|
||||
|
||||
// parsePages pairs each page with the shared layout. They cannot all be parsed
|
||||
// into one template set because every page defines "content".
|
||||
|
||||
+143
-40
@@ -799,7 +799,7 @@ func TestFailedLoginsAreThrottled(t *testing.T) {
|
||||
func TestLoginRedirectIsAllowlisted(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
for _, c := range []struct{ next, want string }{
|
||||
{"admin", "/admin"},
|
||||
{"files", "/files"},
|
||||
{"", "/"},
|
||||
{"https://evil.example.com", "/"},
|
||||
{"//evil.example.com", "/"},
|
||||
@@ -1198,33 +1198,135 @@ func (h *harness) get(t *testing.T, path, token string) *http.Response {
|
||||
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) {
|
||||
// The listing is open to anyone with a token; what changes is its contents.
|
||||
// Only a credential that does not resolve is turned away.
|
||||
func TestFilesPageAccess(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
cases := []struct {
|
||||
for _, c := range []struct {
|
||||
who string
|
||||
token string
|
||||
want int
|
||||
}{
|
||||
{"anonymous", "", http.StatusUnauthorized},
|
||||
{"anonymous", "", http.StatusOK},
|
||||
{"an unknown token", "not-a-token", http.StatusUnauthorized},
|
||||
{"a non-admin token", h.token, http.StatusForbidden},
|
||||
{"a plain token", h.token, http.StatusOK},
|
||||
{"an admin token", h.admin, http.StatusOK},
|
||||
}
|
||||
for _, c := range cases {
|
||||
resp := h.get(t, "/admin", c.token)
|
||||
} {
|
||||
resp := h.get(t, "/files", 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)
|
||||
t.Errorf("GET /files as %s => %s, want %d", c.who, resp.Status, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie is the credential a browser actually uses for this page.
|
||||
func TestAdminPageAcceptsTheSession(t *testing.T) {
|
||||
// The whole point of the page for a token holder: their own uploads come back,
|
||||
// and nobody else's do. Without this there is no way to find a file again once
|
||||
// the link has been lost.
|
||||
func TestFilesPageListsOnlyYourOwnUploads(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/admin", nil)
|
||||
|
||||
h.upload(t, []byte("mine"), map[string]string{
|
||||
"Authorization": "Bearer " + h.token,
|
||||
"Content-Disposition": `attachment; filename="mine.bin"`}).Body.Close()
|
||||
h.upload(t, []byte("theirs"), map[string]string{
|
||||
"Authorization": "Bearer " + h.admin,
|
||||
"Content-Disposition": `attachment; filename="theirs.bin"`}).Body.Close()
|
||||
h.upload(t, []byte("nobodys"), map[string]string{
|
||||
"Content-Disposition": `attachment; filename="nobodys.bin"`}).Body.Close()
|
||||
|
||||
resp := h.get(t, "/files", h.token)
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
page := string(raw)
|
||||
|
||||
if !strings.Contains(page, "mine.bin") {
|
||||
t.Error("a token holder cannot see their own upload")
|
||||
}
|
||||
for _, hidden := range []string{"theirs.bin", "nobodys.bin"} {
|
||||
if strings.Contains(page, hidden) {
|
||||
t.Errorf("the listing shows %q, which belongs to someone else", hidden)
|
||||
}
|
||||
}
|
||||
// The server's own state is an admin's business, not a guest's.
|
||||
for _, secret := range []string{"Free disk", "Tokens", "boss"} {
|
||||
if strings.Contains(page, secret) {
|
||||
t.Errorf("a plain token's listing exposes %q", secret)
|
||||
}
|
||||
}
|
||||
// An owner column would be a column of one repeated name.
|
||||
if strings.Contains(page, `data-label="Owner"`) {
|
||||
t.Error("the listing carries an owner column for a reader who owns everything in it")
|
||||
}
|
||||
|
||||
// The admin, by contrast, sees all three.
|
||||
resp = h.get(t, "/files", h.admin)
|
||||
raw, _ = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
for _, want := range []string{"mine.bin", "theirs.bin", "nobodys.bin"} {
|
||||
if !strings.Contains(string(raw), want) {
|
||||
t.Errorf("the admin listing is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous uploads are recorded against nobody, so the page says so rather
|
||||
// than pretending to be empty or refusing outright.
|
||||
func TestFilesPageAsksAnonymousVisitorsToLogIn(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
h.upload(t, []byte("nobodys"), map[string]string{
|
||||
"Content-Disposition": `attachment; filename="nobodys.bin"`}).Body.Close()
|
||||
|
||||
resp := h.get(t, "/files", "")
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
page := string(raw)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %s, want 200: the page exists, it just needs a token", resp.Status)
|
||||
}
|
||||
if !strings.Contains(page, `href="/login?next=files`) {
|
||||
t.Error("the page does not offer a way to log in and come back")
|
||||
}
|
||||
if strings.Contains(page, "nobodys.bin") {
|
||||
t.Error("an anonymous visitor is shown files they cannot be known to own")
|
||||
}
|
||||
}
|
||||
|
||||
// A token holder may delete what they uploaded, and lands back on the listing.
|
||||
func TestOwnerDeletesFromTheListing(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
res := decode[uploadResult](t, h.upload(t, []byte("mine"), map[string]string{
|
||||
"Authorization": "Bearer " + h.token,
|
||||
"Content-Disposition": `attachment; filename="mine.bin"`}))
|
||||
|
||||
client := *h.ts.Client()
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", strings.NewReader("from=files"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
|
||||
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 != "/files" {
|
||||
t.Errorf("Location = %q, want /files", loc)
|
||||
}
|
||||
if _, err := h.store.Get(res.ID, h.now); err == nil {
|
||||
t.Error("the owner's own file was not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie is the credential a browser actually uses for this page.
|
||||
func TestFilesPageAcceptsTheSession(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/files", nil)
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
@@ -1232,11 +1334,11 @@ func TestAdminPageAcceptsTheSession(t *testing.T) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET /admin with an admin cookie => %s", resp.Status)
|
||||
t.Fatalf("GET /files with an admin cookie => %s", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) {
|
||||
func TestAdminSeesEveryFileAndNoExpiredOnes(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
// One anonymous, one owned, one that will have expired by the time the
|
||||
@@ -1252,18 +1354,18 @@ func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) {
|
||||
"Content-Disposition": `attachment; filename="expired.bin"`}).Body.Close()
|
||||
|
||||
h.now = clock.Add(2 * time.Hour)
|
||||
resp := h.get(t, "/admin", h.admin)
|
||||
resp := h.get(t, "/files", 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)
|
||||
t.Errorf("the admin listing 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")
|
||||
t.Error("the admin listing shows an expired file as though it were still stored")
|
||||
}
|
||||
// Token names and limits are shown; nothing secret is.
|
||||
if !strings.Contains(page, "boss") {
|
||||
@@ -1271,7 +1373,7 @@ func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) {
|
||||
}
|
||||
for _, secret := range []string{h.admin, h.token} {
|
||||
if strings.Contains(page, secret) {
|
||||
t.Error("the admin page echoes a token secret")
|
||||
t.Error("the listing echoes a token secret")
|
||||
}
|
||||
if strings.Contains(page, auth.HashSecret(secret)) {
|
||||
t.Error("the admin page exposes a token hash")
|
||||
@@ -1279,12 +1381,12 @@ func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSortIsRestrictedToKnownColumns(t *testing.T) {
|
||||
func TestFilesSortIsRestrictedToKnownColumns(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 := h.get(t, "/files?sort="+url.QueryEscape(sort), h.admin)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("sort=%q => %s", sort, resp.Status)
|
||||
@@ -1293,14 +1395,14 @@ func TestAdminSortIsRestrictedToKnownColumns(t *testing.T) {
|
||||
}
|
||||
|
||||
// Deleting from the table returns to the table rather than to a dead end.
|
||||
func TestAdminDeleteReturnsToTheTable(t *testing.T) {
|
||||
func TestAdminDeleteReturnsToTheListing(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")
|
||||
form := strings.NewReader("from=files")
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
@@ -1314,8 +1416,8 @@ func TestAdminDeleteReturnsToTheTable(t *testing.T) {
|
||||
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 loc := resp.Header.Get("Location"); loc != "/files" {
|
||||
t.Errorf("Location = %q, want /files", loc)
|
||||
}
|
||||
if _, err := h.store.Get(res.ID, h.now); err == nil {
|
||||
t.Error("the file was not deleted")
|
||||
@@ -1327,7 +1429,7 @@ 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")
|
||||
form := strings.NewReader("from=files")
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
@@ -1342,9 +1444,10 @@ func TestAdminDeleteStillRequiresAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// The header link is the only way to discover the page, so it has to appear
|
||||
// for everyone who has a listing to see - which is anyone logged in, not just
|
||||
// an admin - and for nobody who does not.
|
||||
func TestFilesLinkIsShownToEveryoneLoggedIn(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
for _, c := range []struct {
|
||||
who string
|
||||
@@ -1352,7 +1455,7 @@ func TestAdminLinkIsShownOnlyToAdmins(t *testing.T) {
|
||||
want bool
|
||||
}{
|
||||
{"anonymous", "", false},
|
||||
{"a non-admin token", h.token, false},
|
||||
{"a plain token", h.token, true},
|
||||
{"an admin token", h.admin, true},
|
||||
} {
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
||||
@@ -1365,8 +1468,8 @@ func TestAdminLinkIsShownOnlyToAdmins(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
if got := strings.Contains(string(raw), `href="/files"`); got != c.want {
|
||||
t.Errorf("files link shown to %s = %v, want %v", c.who, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1677,9 +1780,9 @@ func TestHeaderReflectsTheSession(t *testing.T) {
|
||||
present []string
|
||||
absent []string
|
||||
}{
|
||||
{"anonymous", "", []string{`href="/login"`}, []string{`action="/logout"`, `href="/admin"`}},
|
||||
{"a plain token", h.token, []string{`action="/logout"`, ">friend<"}, []string{`href="/login"`, `href="/admin"`}},
|
||||
{"an admin token", h.admin, []string{`action="/logout"`, `href="/admin"`, ">boss<"}, []string{`href="/login"`}},
|
||||
{"anonymous", "", []string{`href="/login"`}, []string{`action="/logout"`, `href="/files"`}},
|
||||
{"a plain token", h.token, []string{`action="/logout"`, ">friend<", `href="/files"`}, []string{`href="/login"`}},
|
||||
{"an admin token", h.admin, []string{`action="/logout"`, `href="/files"`, ">boss<"}, []string{`href="/login"`}},
|
||||
} {
|
||||
for _, path := range []string{"/", "/login"} {
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+path, nil)
|
||||
@@ -1983,7 +2086,7 @@ func TestRotationEndsLiveSessions(t *testing.T) {
|
||||
|
||||
// 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) {
|
||||
func TestFilesSortOrdersByValue(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
// Three files, distinct in every sortable dimension.
|
||||
@@ -2009,7 +2112,7 @@ func TestAdminSortOrdersByValue(t *testing.T) {
|
||||
h.now = clock
|
||||
|
||||
order := func(sortBy string) []string {
|
||||
resp := h.get(t, "/admin?sort="+sortBy, h.admin)
|
||||
resp := h.get(t, "/files?sort="+sortBy, h.admin)
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
var ids []string
|
||||
@@ -2037,11 +2140,11 @@ func TestAdminSortOrdersByValue(t *testing.T) {
|
||||
|
||||
// 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) {
|
||||
func TestFilesPageIsLaidOutForATable(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
h.upload(t, []byte("x"), nil).Body.Close()
|
||||
|
||||
resp := h.get(t, "/admin", h.admin)
|
||||
resp := h.get(t, "/files", h.admin)
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
page := string(raw)
|
||||
|
||||
@@ -1,24 +1,48 @@
|
||||
{{define "content"}}
|
||||
{{if .NeedsToken}}
|
||||
<section class="card">
|
||||
<h1>Administration</h1>
|
||||
<h1>Files</h1>
|
||||
<p class="hint">
|
||||
Uploads are recorded against the token that made them, so this page needs
|
||||
one to have anything to show. Log in and it lists the files you uploaded,
|
||||
with what is left of their lifetime.
|
||||
</p>
|
||||
<p class="hint">
|
||||
An upload made without a token belongs to nobody and cannot be listed here.
|
||||
The link and the delete token handed out at the time are the only way back
|
||||
to it.
|
||||
</p>
|
||||
<p class="actions"><a class="button" href="{{.Base}}login?next=files">Log in</a></p>
|
||||
</section>
|
||||
{{else}}
|
||||
|
||||
<section class="card">
|
||||
<h1>{{if .Admin}}All files{{else}}Your files{{end}}</h1>
|
||||
<dl class="stats">
|
||||
<dt>Files</dt><dd>{{.Count}}{{if .Anonymous}} <em>({{.Anonymous}} anonymous)</em>{{end}}</dd>
|
||||
{{if .Admin}}
|
||||
<dt>Stored</dt><dd>{{.Total}}{{if .Quota}} of {{.Quota}} ({{.QuotaPct}}%){{end}}</dd>
|
||||
{{if .FreeDisk}}<dt>Free disk</dt><dd>{{.FreeDisk}}</dd>{{end}}
|
||||
{{else}}
|
||||
<dt>Uploaded as</dt><dd>{{.User}}</dd>
|
||||
<dt>Holding</dt><dd>{{.Listed}}</dd>
|
||||
{{end}}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Files</h2>
|
||||
<h2>{{if .Admin}}Every upload{{else}}Uploaded with your token{{end}}</h2>
|
||||
{{if not .Objects}}
|
||||
<p class="hint">Nothing stored right now.</p>
|
||||
<p class="hint">
|
||||
{{if .Admin}}Nothing stored right now.{{else}}You have not uploaded anything that is still here. Files you upload while logged in show up on this page until they expire.{{end}}
|
||||
</p>
|
||||
{{else}}
|
||||
<table class="admin files">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><a href="?sort=name">File</a></th>
|
||||
<th class="num"><a href="?sort=size">Size</a></th>
|
||||
<th><a href="?sort=owner">Owner</a></th>
|
||||
{{if .Admin}}<th><a href="?sort=owner">Owner</a></th>{{end}}
|
||||
<th><a href="?sort=created">Uploaded</a></th>
|
||||
<th><a href="?sort=expires">Expires</a></th>
|
||||
<th><span class="visually-hidden">Actions</span></th>
|
||||
@@ -32,12 +56,12 @@
|
||||
<span class="id mono">{{.ID}}{{if .Vanity}} <em title="vanity name">★</em>{{end}}</span>
|
||||
</td>
|
||||
<td data-label="Size" class="num nowrap">{{.Size}}</td>
|
||||
<td data-label="Owner">{{if .Owner}}{{.Owner}}{{else}}<em>anonymous</em>{{end}}</td>
|
||||
{{if $.Admin}}<td data-label="Owner">{{if .Owner}}{{.Owner}}{{else}}<em>anonymous</em>{{end}}</td>{{end}}
|
||||
<td data-label="Uploaded" class="nowrap" title="{{.Created}}">{{.CreatedAgo}}</td>
|
||||
<td data-label="Expires" class="nowrap" title="{{.Expires}}">{{.ExpiresIn}}</td>
|
||||
<td class="actions-cell">
|
||||
<form method="post" action="{{$.Base}}d/{{.ID}}/delete">
|
||||
<input type="hidden" name="from" value="admin">
|
||||
<input type="hidden" name="from" value="files">
|
||||
<button type="submit" class="danger small"
|
||||
data-confirm="Delete {{.Filename}}? This cannot be undone.">Delete</button>
|
||||
</form>
|
||||
@@ -49,6 +73,7 @@
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{if .Admin}}
|
||||
<section class="card">
|
||||
<h2>Tokens</h2>
|
||||
<p class="hint">
|
||||
@@ -76,3 +101,6 @@
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -12,7 +12,7 @@
|
||||
<a class="brand" href="{{.Base}}">Uncensored Send</a>
|
||||
<nav>
|
||||
{{if .User}}
|
||||
{{if .Admin}}<a href="{{.Base}}admin">Administration</a>{{end}}
|
||||
<a href="{{.Base}}files">{{if .Admin}}All files{{else}}Your files{{end}}</a>
|
||||
<span class="who">{{.User}}</span>
|
||||
<form method="post" action="{{.Base}}logout"><button type="submit" class="link">Log out</button></form>
|
||||
{{else}}
|
||||
|
||||
Reference in New Issue
Block a user