Add files page
This commit is contained in:
+56
-21
@@ -10,16 +10,24 @@ import (
|
|||||||
"uncensored-send/internal/config"
|
"uncensored-send/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminPage is the one view that shows every object, regardless of who
|
// filesPage lists uploads. One page serves two readers: an admin sees every
|
||||||
// uploaded it. It exists because "admin" otherwise only means "may delete
|
// object plus what the server as a whole is holding, and a token holder sees
|
||||||
// anyone's file", with no way to see whose files those are.
|
// the files uploaded under their own token, which is otherwise information
|
||||||
type adminPage struct {
|
// they have no way to get back.
|
||||||
|
type filesPage struct {
|
||||||
page
|
page
|
||||||
Objects []adminObject
|
Objects []adminObject
|
||||||
Tokens []adminToken
|
|
||||||
Sort string
|
Sort string
|
||||||
|
|
||||||
Count int
|
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
|
Total string
|
||||||
Quota string // empty when there is no quota
|
Quota string // empty when there is no quota
|
||||||
QuotaPct int
|
QuotaPct int
|
||||||
@@ -79,17 +87,19 @@ func compareExpiry(a, b *time.Time) int {
|
|||||||
return a.Compare(*b)
|
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))
|
lim, err := s.limitsFor(r, credential(r))
|
||||||
switch {
|
if err != nil {
|
||||||
case err != nil:
|
|
||||||
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
|
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
|
||||||
return
|
return
|
||||||
case lim.Anonymous():
|
}
|
||||||
s.fail(w, r, http.StatusUnauthorized, "This page needs an admin token.")
|
if lim.Anonymous() {
|
||||||
return
|
// Not an error: the page exists, it just has nothing to say without a
|
||||||
case !lim.Admin:
|
// token. An anonymous upload is not recorded against anyone.
|
||||||
s.fail(w, r, http.StatusForbidden, "That token is not an admin token.")
|
s.render(w, http.StatusOK, "files.html", filesPage{
|
||||||
|
page: s.page(r, "Files", false),
|
||||||
|
NeedsToken: true,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,16 +110,23 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
now := s.now()
|
now := s.now()
|
||||||
objects := make([]adminObject, 0, s.store.Count())
|
objects := make([]adminObject, 0, s.store.Count())
|
||||||
anonymous := 0
|
anonymous, listed := 0, int64(0)
|
||||||
for _, m := range s.store.List() {
|
for _, m := range s.store.List() {
|
||||||
// Expired objects are logically gone even if the sweeper has not yet
|
// 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.
|
// reached them, so they are not listed as though they were still here.
|
||||||
if m.Expired(now) {
|
if m.Expired(now) {
|
||||||
continue
|
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 == "" {
|
if m.Owner == "" {
|
||||||
anonymous++
|
anonymous++
|
||||||
}
|
}
|
||||||
|
listed += m.Size
|
||||||
objects = append(objects, adminObject{
|
objects = append(objects, adminObject{
|
||||||
ID: m.ID,
|
ID: m.ID,
|
||||||
Filename: m.Filename,
|
Filename: m.Filename,
|
||||||
@@ -127,15 +144,33 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
slices.SortStableFunc(objects, adminSorts[sortBy])
|
slices.SortStableFunc(objects, adminSorts[sortBy])
|
||||||
|
|
||||||
data := adminPage{
|
title := "Your files"
|
||||||
page: s.widePage(r, "Administration"),
|
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,
|
Objects: objects,
|
||||||
Tokens: s.adminTokens(),
|
|
||||||
Sort: sortBy,
|
Sort: sortBy,
|
||||||
Count: len(objects),
|
Count: len(objects),
|
||||||
Total: config.FormatBytes(s.store.Total()),
|
Listed: config.FormatBytes(listed),
|
||||||
Anonymous: anonymous,
|
|
||||||
}
|
}
|
||||||
|
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 {
|
if s.cfg.MaxTotalBytes != config.Unlimited {
|
||||||
data.Quota = config.FormatSize(s.cfg.MaxTotalBytes)
|
data.Quota = config.FormatSize(s.cfg.MaxTotalBytes)
|
||||||
data.QuotaPct = int(min(100, s.store.Total()*100/max(1, 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 {
|
if free, ok := freeBytes(s.store.DataDir()); ok {
|
||||||
data.FreeDisk = config.FormatBytes(free)
|
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
|
// 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})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Deleting from the administration table goes back to it. The destination
|
// Deleting from the listing goes back to it. The destination is built from
|
||||||
// is built from configuration, never from the request, so this cannot be
|
// configuration, never from the request, so this cannot be turned into an
|
||||||
// turned into an open redirect.
|
// open redirect.
|
||||||
if from == "admin" {
|
if from == "files" {
|
||||||
http.Redirect(w, r, s.cfg.BasePath+"admin", http.StatusSeeOther)
|
http.Redirect(w, r, s.cfg.BasePath+"files", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.render(w, http.StatusOK, "error.html", errorPage{
|
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.
|
// it to known page names means the parameter can never name somewhere else.
|
||||||
var loginDestinations = map[string]string{
|
var loginDestinations = map[string]string{
|
||||||
"": "",
|
"": "",
|
||||||
"admin": "admin",
|
"files": "files",
|
||||||
}
|
}
|
||||||
|
|
||||||
func destination(next string) string {
|
func destination(next string) string {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func (s *Server) routes() http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /{$}", s.handleIndex)
|
mux.HandleFunc("GET /{$}", s.handleIndex)
|
||||||
mux.HandleFunc("POST /upload", s.handleUpload)
|
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 /d/{id}", s.handleDownload)
|
||||||
mux.HandleFunc("GET /i/{id}", s.handleInfo)
|
mux.HandleFunc("GET /i/{id}", s.handleInfo)
|
||||||
mux.HandleFunc("POST /d/{id}/delete", s.handleDelete)
|
mux.HandleFunc("POST /d/{id}/delete", s.handleDelete)
|
||||||
@@ -199,7 +199,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", "login.html"}
|
"files.html", "login.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".
|
||||||
|
|||||||
+143
-40
@@ -799,7 +799,7 @@ func TestFailedLoginsAreThrottled(t *testing.T) {
|
|||||||
func TestLoginRedirectIsAllowlisted(t *testing.T) {
|
func TestLoginRedirectIsAllowlisted(t *testing.T) {
|
||||||
h := newHarness(t, nil)
|
h := newHarness(t, nil)
|
||||||
for _, c := range []struct{ next, want string }{
|
for _, c := range []struct{ next, want string }{
|
||||||
{"admin", "/admin"},
|
{"files", "/files"},
|
||||||
{"", "/"},
|
{"", "/"},
|
||||||
{"https://evil.example.com", "/"},
|
{"https://evil.example.com", "/"},
|
||||||
{"//evil.example.com", "/"},
|
{"//evil.example.com", "/"},
|
||||||
@@ -1198,33 +1198,135 @@ func (h *harness) get(t *testing.T, path, token string) *http.Response {
|
|||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// The admin page shows every stored file, so who may open it is the whole
|
// The listing is open to anyone with a token; what changes is its contents.
|
||||||
// security story for this feature.
|
// Only a credential that does not resolve is turned away.
|
||||||
func TestAdminPageAccessControl(t *testing.T) {
|
func TestFilesPageAccess(t *testing.T) {
|
||||||
h := newHarness(t, nil)
|
h := newHarness(t, nil)
|
||||||
cases := []struct {
|
for _, c := range []struct {
|
||||||
who string
|
who string
|
||||||
token string
|
token string
|
||||||
want int
|
want int
|
||||||
}{
|
}{
|
||||||
{"anonymous", "", http.StatusUnauthorized},
|
{"anonymous", "", http.StatusOK},
|
||||||
{"an unknown token", "not-a-token", http.StatusUnauthorized},
|
{"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},
|
{"an admin token", h.admin, http.StatusOK},
|
||||||
}
|
} {
|
||||||
for _, c := range cases {
|
resp := h.get(t, "/files", c.token)
|
||||||
resp := h.get(t, "/admin", c.token)
|
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != c.want {
|
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.
|
// The whole point of the page for a token holder: their own uploads come back,
|
||||||
func TestAdminPageAcceptsTheSession(t *testing.T) {
|
// 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)
|
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})
|
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
|
||||||
resp, err := h.ts.Client().Do(req)
|
resp, err := h.ts.Client().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1232,11 +1334,11 @@ func TestAdminPageAcceptsTheSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
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)
|
h := newHarness(t, nil)
|
||||||
|
|
||||||
// One anonymous, one owned, one that will have expired by the time the
|
// 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()
|
"Content-Disposition": `attachment; filename="expired.bin"`}).Body.Close()
|
||||||
|
|
||||||
h.now = clock.Add(2 * time.Hour)
|
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()
|
defer resp.Body.Close()
|
||||||
raw, _ := io.ReadAll(resp.Body)
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
page := string(raw)
|
page := string(raw)
|
||||||
|
|
||||||
for _, want := range []string{"anonymous.bin", "owned.bin", "friends-file", "friend"} {
|
for _, want := range []string{"anonymous.bin", "owned.bin", "friends-file", "friend"} {
|
||||||
if !strings.Contains(page, want) {
|
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") {
|
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.
|
// Token names and limits are shown; nothing secret is.
|
||||||
if !strings.Contains(page, "boss") {
|
if !strings.Contains(page, "boss") {
|
||||||
@@ -1271,7 +1373,7 @@ func TestAdminPageListsEveryoneAndHidesExpired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, secret := range []string{h.admin, h.token} {
|
for _, secret := range []string{h.admin, h.token} {
|
||||||
if strings.Contains(page, secret) {
|
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)) {
|
if strings.Contains(page, auth.HashSecret(secret)) {
|
||||||
t.Error("the admin page exposes a token hash")
|
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 := newHarness(t, nil)
|
||||||
h.upload(t, []byte("x"), nil).Body.Close()
|
h.upload(t, []byte("x"), nil).Body.Close()
|
||||||
|
|
||||||
for _, sort := range []string{"size", "created", "expires", "name", "owner", "", "../../etc", "nonsense"} {
|
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()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Errorf("sort=%q => %s", sort, resp.Status)
|
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.
|
// 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)
|
h := newHarness(t, nil)
|
||||||
res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil))
|
res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil))
|
||||||
|
|
||||||
client := *h.ts.Client()
|
client := *h.ts.Client()
|
||||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
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, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("Accept", "text/html")
|
req.Header.Set("Accept", "text/html")
|
||||||
@@ -1314,8 +1416,8 @@ func TestAdminDeleteReturnsToTheTable(t *testing.T) {
|
|||||||
if resp.StatusCode != http.StatusSeeOther {
|
if resp.StatusCode != http.StatusSeeOther {
|
||||||
t.Fatalf("status = %s, want 303", resp.Status)
|
t.Fatalf("status = %s, want 303", resp.Status)
|
||||||
}
|
}
|
||||||
if loc := resp.Header.Get("Location"); loc != "/admin" {
|
if loc := resp.Header.Get("Location"); loc != "/files" {
|
||||||
t.Errorf("Location = %q, want /admin", loc)
|
t.Errorf("Location = %q, want /files", loc)
|
||||||
}
|
}
|
||||||
if _, err := h.store.Get(res.ID, h.now); err == nil {
|
if _, err := h.store.Get(res.ID, h.now); err == nil {
|
||||||
t.Error("the file was not deleted")
|
t.Error("the file was not deleted")
|
||||||
@@ -1327,7 +1429,7 @@ func TestAdminDeleteStillRequiresAdmin(t *testing.T) {
|
|||||||
h := newHarness(t, nil)
|
h := newHarness(t, nil)
|
||||||
res := decode[uploadResult](t, h.upload(t, []byte("not yours"), 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, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("Accept", "application/json")
|
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
|
// The header link is the only way to discover the page, so it has to appear
|
||||||
// an admin and never for anyone else.
|
// for everyone who has a listing to see - which is anyone logged in, not just
|
||||||
func TestAdminLinkIsShownOnlyToAdmins(t *testing.T) {
|
// an admin - and for nobody who does not.
|
||||||
|
func TestFilesLinkIsShownToEveryoneLoggedIn(t *testing.T) {
|
||||||
h := newHarness(t, nil)
|
h := newHarness(t, nil)
|
||||||
for _, c := range []struct {
|
for _, c := range []struct {
|
||||||
who string
|
who string
|
||||||
@@ -1352,7 +1455,7 @@ func TestAdminLinkIsShownOnlyToAdmins(t *testing.T) {
|
|||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"anonymous", "", false},
|
{"anonymous", "", false},
|
||||||
{"a non-admin token", h.token, false},
|
{"a plain token", h.token, true},
|
||||||
{"an admin token", h.admin, true},
|
{"an admin token", h.admin, true},
|
||||||
} {
|
} {
|
||||||
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
||||||
@@ -1365,8 +1468,8 @@ func TestAdminLinkIsShownOnlyToAdmins(t *testing.T) {
|
|||||||
}
|
}
|
||||||
raw, _ := io.ReadAll(resp.Body)
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if got := strings.Contains(string(raw), `href="/admin"`); got != c.want {
|
if got := strings.Contains(string(raw), `href="/files"`); got != c.want {
|
||||||
t.Errorf("admin link shown to %s = %v, want %v", c.who, 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
|
present []string
|
||||||
absent []string
|
absent []string
|
||||||
}{
|
}{
|
||||||
{"anonymous", "", []string{`href="/login"`}, []string{`action="/logout"`, `href="/admin"`}},
|
{"anonymous", "", []string{`href="/login"`}, []string{`action="/logout"`, `href="/files"`}},
|
||||||
{"a plain token", h.token, []string{`action="/logout"`, ">friend<"}, []string{`href="/login"`, `href="/admin"`}},
|
{"a plain token", h.token, []string{`action="/logout"`, ">friend<", `href="/files"`}, []string{`href="/login"`}},
|
||||||
{"an admin token", h.admin, []string{`action="/logout"`, `href="/admin"`, ">boss<"}, []string{`href="/login"`}},
|
{"an admin token", h.admin, []string{`action="/logout"`, `href="/files"`, ">boss<"}, []string{`href="/login"`}},
|
||||||
} {
|
} {
|
||||||
for _, path := range []string{"/", "/login"} {
|
for _, path := range []string{"/", "/login"} {
|
||||||
req, _ := http.NewRequest("GET", h.ts.URL+path, nil)
|
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
|
// Sorting has to compare the underlying values, not their rendered form: two
|
||||||
// uploads in the same minute render identically but are not equal.
|
// 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)
|
h := newHarness(t, nil)
|
||||||
|
|
||||||
// Three files, distinct in every sortable dimension.
|
// Three files, distinct in every sortable dimension.
|
||||||
@@ -2009,7 +2112,7 @@ func TestAdminSortOrdersByValue(t *testing.T) {
|
|||||||
h.now = clock
|
h.now = clock
|
||||||
|
|
||||||
order := func(sortBy string) []string {
|
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)
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
var ids []string
|
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
|
// 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.
|
// 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 := newHarness(t, nil)
|
||||||
h.upload(t, []byte("x"), nil).Body.Close()
|
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)
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
page := string(raw)
|
page := string(raw)
|
||||||
|
|||||||
@@ -1,24 +1,48 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
{{if .NeedsToken}}
|
||||||
<section class="card">
|
<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">
|
<dl class="stats">
|
||||||
<dt>Files</dt><dd>{{.Count}}{{if .Anonymous}} <em>({{.Anonymous}} anonymous)</em>{{end}}</dd>
|
<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>
|
<dt>Stored</dt><dd>{{.Total}}{{if .Quota}} of {{.Quota}} ({{.QuotaPct}}%){{end}}</dd>
|
||||||
{{if .FreeDisk}}<dt>Free disk</dt><dd>{{.FreeDisk}}</dd>{{end}}
|
{{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>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Files</h2>
|
<h2>{{if .Admin}}Every upload{{else}}Uploaded with your token{{end}}</h2>
|
||||||
{{if not .Objects}}
|
{{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}}
|
{{else}}
|
||||||
<table class="admin files">
|
<table class="admin files">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><a href="?sort=name">File</a></th>
|
<th><a href="?sort=name">File</a></th>
|
||||||
<th class="num"><a href="?sort=size">Size</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=created">Uploaded</a></th>
|
||||||
<th><a href="?sort=expires">Expires</a></th>
|
<th><a href="?sort=expires">Expires</a></th>
|
||||||
<th><span class="visually-hidden">Actions</span></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>
|
<span class="id mono">{{.ID}}{{if .Vanity}} <em title="vanity name">★</em>{{end}}</span>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Size" class="num nowrap">{{.Size}}</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="Uploaded" class="nowrap" title="{{.Created}}">{{.CreatedAgo}}</td>
|
||||||
<td data-label="Expires" class="nowrap" title="{{.Expires}}">{{.ExpiresIn}}</td>
|
<td data-label="Expires" class="nowrap" title="{{.Expires}}">{{.ExpiresIn}}</td>
|
||||||
<td class="actions-cell">
|
<td class="actions-cell">
|
||||||
<form method="post" action="{{$.Base}}d/{{.ID}}/delete">
|
<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"
|
<button type="submit" class="danger small"
|
||||||
data-confirm="Delete {{.Filename}}? This cannot be undone.">Delete</button>
|
data-confirm="Delete {{.Filename}}? This cannot be undone.">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -49,6 +73,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{{if .Admin}}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Tokens</h2>
|
<h2>Tokens</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -76,3 +101,6 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<a class="brand" href="{{.Base}}">Uncensored Send</a>
|
<a class="brand" href="{{.Base}}">Uncensored Send</a>
|
||||||
<nav>
|
<nav>
|
||||||
{{if .User}}
|
{{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>
|
<span class="who">{{.User}}</span>
|
||||||
<form method="post" action="{{.Base}}logout"><button type="submit" class="link">Log out</button></form>
|
<form method="post" action="{{.Base}}logout"><button type="submit" class="link">Log out</button></form>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
Reference in New Issue
Block a user