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"
)
// 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
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
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"),
Objects: objects,
Tokens: s.adminTokens(),
Sort: sortBy,
Count: len(objects),
Total: config.FormatBytes(s.store.Total()),
Anonymous: anonymous,
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,
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 {
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
+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})
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{
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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
View File
@@ -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)