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
+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)