Add files page

This commit is contained in:
2026-09-13 11:28:17 +02:00
parent ab0e1b47d8
commit 30826f3671
7 changed files with 247 additions and 81 deletions
+59 -24
View File
@@ -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
Listed string // bytes held by the files actually listed
Count int // 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 {
Objects: objects, title = "All files"
Tokens: s.adminTokens(),
Sort: sortBy,
Count: len(objects),
Total: config.FormatBytes(s.store.Total()),
Anonymous: anonymous,
} }
// 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,
Sort: sortBy,
Count: len(objects),
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 { 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
+5 -5
View File
@@ -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{
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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
View File
@@ -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>
<dt>Stored</dt><dd>{{.Total}}{{if .Quota}} of {{.Quota}} ({{.QuotaPct}}%){{end}}</dd> {{if .Admin}}
{{if .FreeDisk}}<dt>Free disk</dt><dd>{{.FreeDisk}}</dd>{{end}} <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> </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}}
+1 -1
View File
@@ -12,7 +12,7 @@
<a class="brand" href="{{.Base}}">Uncensored&nbsp;Send</a> <a class="brand" href="{{.Base}}">Uncensored&nbsp;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}}